Skip to main content

max / makenotwork

Move mnw-cli TUI launch() event loop into run.rs tui/mod.rs held a flat App struct plus the ~386-line launch() event loop. Move launch() (terminal setup + render/input task + per-screen dispatch) into tui/run.rs, re-exported as `pub use run::launch` so tui::launch (the entry called from ssh/handler.rs) is unchanged. run.rs uses `use super::*`, which inherits mod.rs's named imports; the clippy too_many_arguments allow travels with launch. mod.rs drops from 902 to 518 lines. The flat App god-struct decomposition into per-screen sub-state is left as a separate change — it reshapes field access across every handler and render module rather than moving a file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 15:53 UTC
Commit: e65630c1af9e89c20fc14aa2f963c20e0cdfe8b0
Parent: 7e6f787
2 files changed, +396 insertions, -389 deletions
@@ -34,6 +34,9 @@ use crate::staging::{self, StagedFile};
34 34 use input::*;
35 35 use loading::*;
36 36
37 + mod run;
38 + pub use run::launch;
39 +
37 40 /// Events sent to the TUI event loop.
38 41 pub enum AppEvent {
39 42 /// Raw input bytes from the SSH channel.
@@ -382,395 +385,6 @@ impl App {
382 385 }
383 386 }
384 387
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 388 fn format_edit_prompt(field: EditField, buffer: &str) -> String {
775 389 let field_name = match field {
776 390 EditField::Title => "Title",
@@ -0,0 +1,393 @@
1 + //! TUI event loop. `launch` sets up the terminal, spawns the render/input
2 + //! task, and drives the per-screen handlers. Split out of tui/mod.rs.
3 +
4 + use super::*;
5 +
6 + /// Launch the TUI event loop in a background task.
7 + #[allow(clippy::too_many_arguments)]
8 + pub fn launch(
9 + writer: TerminalHandle,
10 + user: UserInfo,
11 + cols: u16,
12 + rows: u16,
13 + session_handle: russh::server::Handle,
14 + channel_id: russh::ChannelId,
15 + api: MnwApiClient,
16 + staging_dir: PathBuf,
17 + ) -> anyhow::Result<AppHandle> {
18 + let mut writer = writer;
19 + // Enter alternate screen, hide cursor, enable raw-like mode
20 + use std::io::Write;
21 + let _ = writer.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H");
22 + let _ = writer.flush();
23 + let backend = CrosstermBackend::new(writer);
24 + let options = ratatui::TerminalOptions {
25 + viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, cols, rows)),
26 + };
27 + let mut terminal = Terminal::with_options(backend, options)?;
28 +
29 + let (tx, mut rx) = mpsc::channel::<AppEvent>(64);
30 + let handle = AppHandle { tx: tx.clone() };
31 +
32 + // Kick off initial data load
33 + let user_id = user.user_id.clone();
34 + let api_clone = api.clone();
35 + let tx_clone = tx.clone();
36 + tokio::spawn(async move {
37 + load_home_data(&api_clone, &user_id, &tx_clone).await;
38 + });
39 +
40 + tokio::spawn(async move {
41 + let mut app = App::new(user);
42 + let mut screen = Screen::Home;
43 + let staging_dir = staging_dir;
44 +
45 + /// Write escape codes to leave alternate screen and restore cursor.
46 + fn cleanup(terminal: &mut Terminal<CrosstermBackend<TerminalHandle>>) {
47 + use std::io::Write;
48 + let be = terminal.backend_mut();
49 + let _ = be.write_all(b"\x1b[?25h\x1b[?1049l");
50 + let _ = be.flush();
51 + }
52 +
53 + // Initial render (loading state)
54 + if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) {
55 + tracing::error!(error = ?e, "TUI: initial render failed");
56 + cleanup(&mut terminal);
57 + return;
58 + }
59 +
60 + while let Some(event) = rx.recv().await {
61 + match event {
62 + AppEvent::Input(data) => {
63 + if let Some(key) = parse_key(&data) {
64 + // Global quit
65 + if (key.modifiers.contains(KeyModifiers::CONTROL)
66 + && key.code == KeyCode::Char('c'))
67 + || matches!(
68 + (&screen, key.code),
69 + (Screen::Home, KeyCode::Char('q') | KeyCode::Char('Q'))
70 + )
71 + {
72 + tracing::info!(user = %app.user.username, "user quit");
73 + cleanup(&mut terminal);
74 + let _ = session_handle.close(channel_id).await;
75 + return;
76 + }
77 +
78 + match screen {
79 + Screen::Home => {
80 + handle_home_input(
81 + key, &mut app, &mut screen, &api, &tx, &staging_dir,
82 + )
83 + .await;
84 + }
85 + Screen::Project(_) => {
86 + handle_project_input(
87 + key, &mut app, &mut screen, &api, &tx,
88 + )
89 + .await;
90 + }
91 + Screen::Upload => {
92 + handle_upload_input(
93 + key, &mut app, &mut screen, &api, &tx, &staging_dir,
94 + )
95 + .await;
96 + }
97 + Screen::Item(..) => {
98 + handle_item_input(
99 + key, &mut app, &mut screen, &api, &tx,
100 + )
101 + .await;
102 + }
103 + Screen::Blog(..) => {
104 + handle_blog_input(
105 + key, &mut app, &mut screen, &api, &tx,
106 + )
107 + .await;
108 + }
109 + Screen::Promo => {
110 + handle_promo_input(
111 + key, &mut app, &mut screen, &api, &tx,
112 + )
113 + .await;
114 + }
115 + Screen::Keys(..) => {
116 + handle_keys_input(
117 + key, &mut app, &mut screen, &api, &tx,
118 + )
119 + .await;
120 + }
121 + Screen::Analytics => {
122 + handle_analytics_input(
123 + key, &mut app, &mut screen, &api, &tx,
124 + )
125 + .await;
126 + }
127 + Screen::Settings => {
128 + handle_settings_input(
129 + key, &mut app, &mut screen, &api, &tx,
130 + )
131 + .await;
132 + }
133 + Screen::Collections => {
134 + handle_collections_input(
135 + key, &mut app, &mut screen, &api, &tx,
136 + )
137 + .await;
138 + }
139 + Screen::Tiers(..) => {
140 + handle_tiers_input(
141 + key, &mut app, &mut screen,
142 + )
143 + .await;
144 + }
145 + }
146 + }
147 + }
148 + AppEvent::Resize(cols, rows) => {
149 + let rect = ratatui::layout::Rect::new(0, 0, cols, rows);
150 + let _ = terminal.resize(rect);
151 + }
152 + AppEvent::DataLoaded(payload) => match payload {
153 + DataPayload::Home { projects, stats } => {
154 + app.projects = projects;
155 + app.stats = Some(stats);
156 + app.loading = false;
157 + app.selected_index = 0;
158 + }
159 + DataPayload::ProjectItems { items } => {
160 + app.items = items;
161 + app.loading = false;
162 + app.selected_index = 0;
163 + app.selected_items.clear();
164 + }
165 + DataPayload::StagedFiles { files, storage } => {
166 + app.staged_files = files;
167 + if let Some(s) = storage {
168 + app.storage_info = Some(s);
169 + }
170 + app.sync_metadata();
171 + app.loading = false;
172 + if app.selected_index >= app.staged_files.len() && !app.staged_files.is_empty() {
173 + app.selected_index = app.staged_files.len() - 1;
174 + }
175 + }
176 + DataPayload::ItemDetail { detail, versions } => {
177 + app.item_detail = Some(detail);
178 + app.item_versions = versions;
179 + app.loading = false;
180 + app.selected_index = 0;
181 + }
182 + DataPayload::ItemUpdated { detail } => {
183 + app.item_detail = Some(detail);
184 + app.item_status = Some("Updated".to_string());
185 + app.item_editing = None;
186 + app.edit_buffer.clear();
187 + }
188 + DataPayload::ItemDeleted => {
189 + app.item_status = Some("Deleted".to_string());
190 + // Navigate back to project view
191 + if let Screen::Item(project_idx, _) = &screen {
192 + let pidx = *project_idx;
193 + screen = Screen::Project(pidx);
194 + app.item_detail = None;
195 + app.item_versions.clear();
196 + app.item_status = None;
197 + app.selected_index = 0;
198 + app.loading = true;
199 +
200 + if let Some(p) = app.projects.get(pidx) {
201 + let api = api.clone();
202 + let project_id = p.id.clone();
203 + let user_id = app.user.user_id.clone();
204 + let tx = tx.clone();
205 + tokio::spawn(async move {
206 + load_project_items(&api, &project_id, &user_id, &tx).await;
207 + });
208 + }
209 + }
210 + }
211 + DataPayload::ItemActionError { error } => {
212 + app.item_status = Some(format!("Error: {}", error));
213 + }
214 + DataPayload::BlogPosts { posts } => {
215 + app.blog_posts = posts;
216 + app.loading = false;
217 + app.selected_index = 0;
218 + }
219 + DataPayload::BlogCreated => {
220 + app.blog_creating = false;
221 + app.blog_create_step = None;
222 + app.blog_create_title.clear();
223 + app.blog_create_body.clear();
224 + app.edit_buffer.clear();
225 + app.blog_status = Some("Post created".to_string());
226 + // Reload blog posts
227 + if let Screen::Blog(_, ref project_id) = screen {
228 + let api = api.clone();
229 + let user_id = app.user.user_id.clone();
230 + let project_id = project_id.clone();
231 + let tx = tx.clone();
232 + tokio::spawn(async move {
233 + load_blog_posts(&api, &user_id, &project_id, &tx).await;
234 + });
235 + }
236 + }
237 + DataPayload::PromoCodes { codes } => {
238 + app.promo_codes = codes;
239 + app.loading = false;
240 + app.selected_index = 0;
241 + }
242 + DataPayload::LicenseKeys { keys: k } => {
243 + app.license_keys = k;
244 + app.loading = false;
245 + app.selected_index = 0;
246 + }
247 + DataPayload::GenericSuccess { message } => {
248 + // Use as status on whatever screen is active
249 + match screen {
250 + Screen::Blog(..) => app.blog_status = Some(message),
251 + Screen::Promo => app.promo_status = Some(message),
252 + Screen::Keys(..) => app.keys_status = Some(message),
253 + _ => {}
254 + }
255 + }
256 + DataPayload::GenericError { error } => {
257 + let msg = format!("Error: {}", error);
258 + match screen {
259 + Screen::Blog(..) => app.blog_status = Some(msg),
260 + Screen::Promo => {
261 + app.promo_status = Some(msg);
262 + app.promo_editing_step = None;
263 + }
264 + Screen::Keys(..) => app.keys_status = Some(msg),
265 + Screen::Analytics => app.analytics_status = Some(msg),
266 + Screen::Settings => app.settings_status = Some(msg),
267 + _ => app.item_status = Some(msg),
268 + }
269 + }
270 + DataPayload::Analytics { data } => {
271 + app.analytics_data = Some(data);
272 + app.loading = false;
273 + }
274 + DataPayload::Transactions { txs } => {
275 + app.transactions = txs;
276 + app.loading = false;
277 + app.selected_index = 0;
278 + }
279 + DataPayload::ExportCsv { csv, row_count } => {
280 + app.analytics_status =
281 + Some(format!("Exported {} rows ({} bytes)", row_count, csv.len()));
282 + }
283 + DataPayload::Settings { keys, storage } => {
284 + app.ssh_keys = keys;
285 + if let Some(s) = storage {
286 + app.storage_info = Some(s);
287 + }
288 + app.loading = false;
289 + app.selected_index = 0;
290 + }
291 + DataPayload::ItemTags { tags } => {
292 + app.item_tags = tags;
293 + }
294 + DataPayload::TagSearchResults { results } => {
295 + app.tag_search_results = results;
296 + app.tag_searching = false;
297 + }
298 + DataPayload::CollectionsList { collections: c } => {
299 + app.collections = c;
300 + app.loading = false;
301 + app.selected_index = 0;
302 + }
303 + DataPayload::TiersList { tiers: t } => {
304 + app.tiers = t;
305 + app.loading = false;
306 + app.selected_index = 0;
307 + }
308 + DataPayload::BulkActionComplete { message } => {
309 + app.item_status = Some(message);
310 + app.selected_items.clear();
311 + // Reload project items
312 + if let Screen::Project(pidx) = &screen {
313 + if let Some(p) = app.projects.get(*pidx) {
314 + app.loading = true;
315 + let api = api.clone();
316 + let project_id = p.id.clone();
317 + let user_id = app.user.user_id.clone();
318 + let tx = tx.clone();
319 + tokio::spawn(async move {
320 + load_project_items(&api, &project_id, &user_id, &tx).await;
321 + });
322 + }
323 + }
324 + }
325 + DataPayload::ProjectReload { project_idx } => {
326 + if let Some(p) = app.projects.get(project_idx) {
327 + app.loading = true;
328 + let api = api.clone();
329 + let project_id = p.id.clone();
330 + let user_id = app.user.user_id.clone();
331 + let tx = tx.clone();
332 + tokio::spawn(async move {
333 + load_project_items(&api, &project_id, &user_id, &tx).await;
334 + });
335 + }
336 + }
337 + DataPayload::PublishResult {
338 + filename,
339 + success,
340 + error,
341 + } => {
342 + app.publishing = false;
343 + if success {
344 + app.upload_status = Some(format!("Published {}", filename));
345 + // Reload staged files
346 + let staging_dir = staging_dir.clone();
347 + let api = api.clone();
348 + let user_id = app.user.user_id.clone();
349 + let tx = tx.clone();
350 + tokio::spawn(async move {
351 + load_staged_files(&staging_dir, &api, &user_id, &tx).await;
352 + });
353 + } else {
354 + app.upload_status = Some(format!(
355 + "Error: {}",
356 + error.unwrap_or_else(|| "unknown error".to_string())
357 + ));
358 + }
359 + }
360 + },
361 + }
362 +
363 + // Re-render after every event
364 + if let Err(e) = terminal.draw(|frame| match &screen {
365 + Screen::Home => home::render(frame, &app),
366 + Screen::Project(idx) => {
367 + if let Some(p) = app.projects.get(*idx) {
368 + project::render(frame, &app, p);
369 + } else {
370 + home::render(frame, &app);
371 + }
372 + }
373 + Screen::Upload => upload::render(frame, &app),
374 + Screen::Item(..) => item::render(frame, &app),
375 + Screen::Blog(..) => blog::render(frame, &app),
376 + Screen::Promo => promo::render(frame, &app),
377 + Screen::Keys(..) => keys::render(frame, &app),
378 + Screen::Analytics => analytics::render(frame, &app),
379 + Screen::Settings => settings::render(frame, &app),
380 + Screen::Collections => collections::render(frame, &app),
381 + Screen::Tiers(..) => tiers::render(frame, &app),
382 + }) {
383 + tracing::error!(error = ?e, "render failed");
384 + cleanup(&mut terminal);
385 + return;
386 + }
387 + }
388 + // Event loop ended (channel dropped)
389 + cleanup(&mut terminal);
390 + });
391 +
392 + Ok(handle)
393 + }