Skip to main content

max / makenotwork

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