1use anyhow::Result;
14use crossterm::event::{
15 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
16 KeyModifiers,
17};
18use crossterm::execute;
19use crossterm::terminal::{
20 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
21};
22use ratatui::backend::CrosstermBackend;
23use ratatui::style::Color;
24use ratatui::Terminal;
25use std::collections::{HashSet, VecDeque};
26use std::path::{Path, PathBuf};
27use std::time::{Duration, Instant};
28
29use crate::client::RommClient;
30use crate::commands::library_scan::ScanCacheInvalidate;
31use crate::config::{
32 auth_for_persist_merge, normalize_romm_origin, resolved_save_dir, Config, ExtrasDefaults,
33 SaveSyncConfig,
34};
35use crate::core::cache::{RomCache, RomCacheKey};
36use crate::core::download::DownloadManager;
37use crate::core::extras::has_update_or_dlc_extras;
38use crate::core::startup_library_snapshot;
39use crate::endpoints::device::{DeviceSchema, ListDevices};
40use crate::endpoints::roms::GetRoms;
41use crate::endpoints::sync::{SyncSessionSchema, TriggerPushPull};
42use crate::types::{Collection, Platform, RomList, SaveMetadata};
43use crate::update::UpdateStatus;
44
45use super::keyboard_help;
46use super::screens::connected_splash::{self, StartupSplash};
47use super::screens::settings::SettingsRow;
48use super::screens::setup_wizard::SetupWizard;
49use super::screens::{
50 BrowseScreen, DownloadScreen, ExecuteScreen, ExtrasPickerScreen, GameDetailPrevious,
51 GameDetailScreen, LibraryBrowseScreen, MainMenuScreen, ResultDetailScreen, ResultScreen,
52 SearchScreen, SettingsScreen,
53};
54use crate::feature_compat::{save_sync_compatibility, SaveSyncCompatibility};
55use crate::openapi::{resolve_path_template, EndpointRegistry};
56
57struct LibraryMetadataRefreshDone {
59 gen: u64,
60 platforms: Vec<Platform>,
61 collections: Vec<Collection>,
62 collection_digest: Vec<startup_library_snapshot::CollectionDigestEntry>,
63 warnings: Vec<String>,
64}
65
66struct CollectionPrefetchDone {
67 key: RomCacheKey,
68 expected: u64,
69 roms: Option<RomList>,
70 warning: Option<String>,
71}
72
73enum RomLoadEvent {
74 Batch(RomList),
75 Failed(String),
76 Complete,
77}
78
79struct RomLoadDone {
81 gen: u64,
82 key: Option<RomCacheKey>,
83 expected: u64,
84 event: RomLoadEvent,
85 context: &'static str,
86 started: Instant,
87}
88
89enum SearchLoadEvent {
90 Batch(RomList),
91 Failed(String),
92 Complete,
93}
94
95struct SearchLoadDone {
96 query: String,
97 event: SearchLoadEvent,
98}
99
100struct CoverLoadDone {
101 rom_id: u64,
102 result: Result<image::DynamicImage, String>,
103}
104
105struct SaveListDone {
106 rom_id: u64,
107 result: Result<Vec<SaveMetadata>, String>,
108}
109
110struct SaveUploadDone {
111 rom_id: u64,
112 result: Result<(), String>,
113}
114
115struct SaveDownloadDone {
116 rom_id: u64,
117 result: Result<PathBuf, String>,
118}
119
120struct DeviceListDone {
121 result: Result<Vec<DeviceSchema>, String>,
122}
123
124struct SyncPushPullDone {
125 result: Result<SyncSessionSchema, String>,
126}
127
128struct StartupUpdatePrompt {
129 status: UpdateStatus,
130 updating: bool,
131}
132
133type DeferredLoadRoms = (
135 Option<RomCacheKey>,
136 Option<GetRoms>,
137 u64,
138 &'static str,
139 Instant,
140);
141
142#[inline]
143fn primary_rom_load_result_is_current(done_gen: u64, current_gen: u64) -> bool {
144 done_gen == current_gen
145}
146
147fn safe_path_segment(input: &str) -> String {
148 let cleaned: String = input
149 .chars()
150 .map(|c| {
151 if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') {
152 c
153 } else {
154 '_'
155 }
156 })
157 .collect();
158 let trimmed = cleaned.trim().trim_matches('.').trim();
159 if trimmed.is_empty() {
160 "game".to_string()
161 } else {
162 trimmed.to_string()
163 }
164}
165
166fn unique_save_path(dir: &Path, file_name: &str) -> PathBuf {
167 let safe_name = safe_path_segment(file_name);
168 let base = Path::new(&safe_name)
169 .file_stem()
170 .and_then(|s| s.to_str())
171 .unwrap_or("save");
172 let ext = Path::new(&safe_name).extension().and_then(|s| s.to_str());
173 let mut candidate = dir.join(&safe_name);
174 let mut n = 1u32;
175 while candidate.exists() {
176 let name = match ext {
177 Some(ext) if !ext.is_empty() => format!("{base}-{n}.{ext}"),
178 _ => format!("{base}-{n}"),
179 };
180 candidate = dir.join(name);
181 n += 1;
182 }
183 candidate
184}
185
186pub enum AppScreen {
195 MainMenu(MainMenuScreen),
196 LibraryBrowse(LibraryBrowseScreen),
197 Search(SearchScreen),
198 Settings(SettingsScreen),
199 Browse(BrowseScreen),
200 Execute(ExecuteScreen),
201 Result(ResultScreen),
202 ResultDetail(ResultDetailScreen),
203 GameDetail(Box<GameDetailScreen>),
204 ExtrasPicker(Box<ExtrasPickerScreen>),
205 Download(DownloadScreen),
206 SetupWizard(Box<crate::tui::screens::setup_wizard::SetupWizard>),
207}
208
209struct LibraryUploadComplete {
211 platform_id: u64,
212 scan_after: bool,
213}
214
215pub struct App {
224 pub screen: AppScreen,
225 client: RommClient,
226 config: Config,
227 registry: EndpointRegistry,
228 server_version: Option<String>,
230 save_sync_compat: SaveSyncCompatibility,
231 rom_cache: RomCache,
232 downloads: DownloadManager,
233 screen_before_download: Option<AppScreen>,
235 deferred_load_roms: Option<DeferredLoadRoms>,
237 startup_splash: Option<StartupSplash>,
239 pub global_error: Option<String>,
240 pub global_notice: Option<String>,
241 show_keyboard_help: bool,
242 startup_update_prompt: Option<StartupUpdatePrompt>,
243 library_metadata_rx: Option<tokio::sync::mpsc::UnboundedReceiver<LibraryMetadataRefreshDone>>,
245 library_metadata_refresh_gen: u64,
247 collection_prefetch_rx: tokio::sync::mpsc::UnboundedReceiver<CollectionPrefetchDone>,
248 collection_prefetch_tx: tokio::sync::mpsc::UnboundedSender<CollectionPrefetchDone>,
249 collection_prefetch_queue: VecDeque<(RomCacheKey, GetRoms, u64)>,
250 collection_prefetch_queued_keys: HashSet<RomCacheKey>,
251 collection_prefetch_inflight_keys: HashSet<RomCacheKey>,
252 rom_load_gen: u64,
254 rom_load_rx: tokio::sync::mpsc::UnboundedReceiver<RomLoadDone>,
255 rom_load_tx: tokio::sync::mpsc::UnboundedSender<RomLoadDone>,
256 rom_load_task: Option<tokio::task::JoinHandle<()>>,
257 search_load_rx: tokio::sync::mpsc::UnboundedReceiver<SearchLoadDone>,
258 search_load_tx: tokio::sync::mpsc::UnboundedSender<SearchLoadDone>,
259 search_load_task: Option<tokio::task::JoinHandle<()>>,
260 cover_load_rx: tokio::sync::mpsc::UnboundedReceiver<CoverLoadDone>,
261 cover_load_tx: tokio::sync::mpsc::UnboundedSender<CoverLoadDone>,
262 cover_load_task: Option<tokio::task::JoinHandle<()>>,
263 library_scan_rx: Option<tokio::sync::mpsc::UnboundedReceiver<Result<(), String>>>,
265 library_scan_inflight: bool,
266 library_scan_pending_invalidate: Option<ScanCacheInvalidate>,
268 force_rom_reload_after_metadata: bool,
270 library_upload_inflight: bool,
272 library_upload_progress_rx: Option<tokio::sync::mpsc::UnboundedReceiver<(u64, u64)>>,
273 library_upload_done_rx:
274 Option<tokio::sync::mpsc::UnboundedReceiver<Result<LibraryUploadComplete, String>>>,
275 save_list_rx: tokio::sync::mpsc::UnboundedReceiver<SaveListDone>,
276 save_list_tx: tokio::sync::mpsc::UnboundedSender<SaveListDone>,
277 save_upload_rx: tokio::sync::mpsc::UnboundedReceiver<SaveUploadDone>,
278 save_upload_tx: tokio::sync::mpsc::UnboundedSender<SaveUploadDone>,
279 save_download_rx: tokio::sync::mpsc::UnboundedReceiver<SaveDownloadDone>,
280 save_download_tx: tokio::sync::mpsc::UnboundedSender<SaveDownloadDone>,
281 device_list_rx: tokio::sync::mpsc::UnboundedReceiver<DeviceListDone>,
282 device_list_tx: tokio::sync::mpsc::UnboundedSender<DeviceListDone>,
283 sync_push_pull_rx: tokio::sync::mpsc::UnboundedReceiver<SyncPushPullDone>,
284 sync_push_pull_tx: tokio::sync::mpsc::UnboundedSender<SyncPushPullDone>,
285}
286
287impl App {
288 fn blocks_global_d_shortcut(&self) -> bool {
289 let base = match &self.screen {
290 AppScreen::Search(_) | AppScreen::Settings(_) | AppScreen::SetupWizard(_) => true,
291 AppScreen::LibraryBrowse(lib) => {
292 lib.any_search_bar_open() || lib.any_upload_prompt_open()
293 }
294 _ => false,
295 };
296 base || self.library_upload_inflight
297 }
298
299 fn allows_global_question_help(&self) -> bool {
300 match &self.screen {
301 AppScreen::Search(_) | AppScreen::SetupWizard(_) | AppScreen::Execute(_) => false,
302 AppScreen::LibraryBrowse(lib)
303 if lib.any_search_bar_open() || lib.any_upload_prompt_open() =>
304 {
305 false
306 }
307 AppScreen::Settings(s) if s.editing || s.path_picker.is_some() => false,
308 _ => true,
309 }
310 }
311
312 fn is_force_quit_key(key: &crossterm::event::KeyEvent) -> bool {
313 key.kind == KeyEventKind::Press
314 && key.modifiers.contains(KeyModifiers::CONTROL)
315 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
316 }
317
318 fn selected_rom_request_for_library(
319 lib: &super::screens::library_browse::LibraryBrowseScreen,
320 ) -> Option<GetRoms> {
321 match lib.subsection {
322 super::screens::library_browse::LibrarySubsection::ByConsole => {
323 lib.get_roms_request_platform()
324 }
325 super::screens::library_browse::LibrarySubsection::ByCollection => {
326 lib.get_roms_request_collection()
327 }
328 }
329 }
330
331 pub fn new(
333 client: RommClient,
334 config: Config,
335 registry: EndpointRegistry,
336 server_version: Option<String>,
337 startup_splash: Option<StartupSplash>,
338 startup_update: Option<UpdateStatus>,
339 ) -> Self {
340 let (prefetch_tx, prefetch_rx) = tokio::sync::mpsc::unbounded_channel();
341 let (rom_load_tx, rom_load_rx) = tokio::sync::mpsc::unbounded_channel();
342 let (search_load_tx, search_load_rx) = tokio::sync::mpsc::unbounded_channel();
343 let (cover_load_tx, cover_load_rx) = tokio::sync::mpsc::unbounded_channel();
344 let (save_list_tx, save_list_rx) = tokio::sync::mpsc::unbounded_channel();
345 let (save_upload_tx, save_upload_rx) = tokio::sync::mpsc::unbounded_channel();
346 let (save_download_tx, save_download_rx) = tokio::sync::mpsc::unbounded_channel();
347 let (device_list_tx, device_list_rx) = tokio::sync::mpsc::unbounded_channel();
348 let (sync_push_pull_tx, sync_push_pull_rx) = tokio::sync::mpsc::unbounded_channel();
349 let save_sync_compat = save_sync_compatibility(®istry);
350 Self {
351 screen: AppScreen::MainMenu(MainMenuScreen::new()),
352 client,
353 config,
354 registry,
355 server_version,
356 save_sync_compat,
357 rom_cache: RomCache::load(),
358 downloads: DownloadManager::new(),
359 screen_before_download: None,
360 deferred_load_roms: None,
361 startup_splash,
362 global_error: None,
363 global_notice: None,
364 show_keyboard_help: false,
365 startup_update_prompt: startup_update.map(|status| StartupUpdatePrompt {
366 status,
367 updating: false,
368 }),
369 library_metadata_rx: None,
370 library_metadata_refresh_gen: 0,
371 collection_prefetch_rx: prefetch_rx,
372 collection_prefetch_tx: prefetch_tx,
373 collection_prefetch_queue: VecDeque::new(),
374 collection_prefetch_queued_keys: HashSet::new(),
375 collection_prefetch_inflight_keys: HashSet::new(),
376 rom_load_gen: 0,
377 rom_load_rx,
378 rom_load_tx,
379 rom_load_task: None,
380 search_load_rx,
381 search_load_tx,
382 search_load_task: None,
383 cover_load_rx,
384 cover_load_tx,
385 cover_load_task: None,
386 library_scan_rx: None,
387 library_scan_inflight: false,
388 library_scan_pending_invalidate: None,
389 force_rom_reload_after_metadata: false,
390 library_upload_inflight: false,
391 library_upload_progress_rx: None,
392 library_upload_done_rx: None,
393 save_list_rx,
394 save_list_tx,
395 save_upload_rx,
396 save_upload_tx,
397 save_download_rx,
398 save_download_tx,
399 device_list_rx,
400 device_list_tx,
401 sync_push_pull_rx,
402 sync_push_pull_tx,
403 }
404 }
405
406 fn spawn_library_metadata_refresh(&mut self) {
407 self.library_metadata_refresh_gen = self.library_metadata_refresh_gen.saturating_add(1);
408 let gen = self.library_metadata_refresh_gen;
409 let client = self.client.clone();
410 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
411 self.library_metadata_rx = Some(rx);
412 tokio::spawn(async move {
413 let fetch = startup_library_snapshot::fetch_merged_library_metadata(&client).await;
414 let _ = tx.send(LibraryMetadataRefreshDone {
415 gen,
416 platforms: fetch.platforms,
417 collections: fetch.collections,
418 collection_digest: fetch.collection_digest,
419 warnings: fetch.warnings,
420 });
421 });
422 }
423
424 pub fn poll_background_tasks(&mut self) {
426 self.poll_library_metadata_refresh();
427 self.poll_rom_load_results();
428 self.poll_collection_prefetch_results();
429 self.poll_search_load_results();
430 self.poll_cover_load_results();
431 self.poll_save_results();
432 self.poll_settings_results();
433 self.poll_library_upload();
434 self.poll_library_scan();
435 self.drive_collection_prefetch_scheduler();
436 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
437 lib.poll_footer_clear();
438 }
439 }
440
441 fn spawn_library_rescan_worker(&mut self, cache_on_success: ScanCacheInvalidate) {
442 if self.library_scan_inflight {
443 return;
444 }
445 self.library_scan_inflight = true;
446 self.library_scan_pending_invalidate = Some(cache_on_success);
447 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
448 lib.set_metadata_footer(Some("Server library scan running…".into()));
449 }
450 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
451 self.library_scan_rx = Some(rx);
452 let client = self.client.clone();
453 tokio::spawn(async move {
454 let result = async {
455 let start =
456 crate::commands::library_scan::start_scan_library(&client, None).await?;
457 crate::commands::library_scan::wait_for_task_terminal(
458 &client,
459 &start.task_id,
460 Duration::from_secs(3600),
461 None,
462 |_| {},
463 )
464 .await?;
465 Ok::<(), anyhow::Error>(())
466 }
467 .await
468 .map_err(|e| e.to_string());
469 let _ = tx.send(result);
470 });
471 }
472
473 fn poll_library_scan(&mut self) {
474 let Some(rx) = &mut self.library_scan_rx else {
475 return;
476 };
477 match rx.try_recv() {
478 Ok(result) => {
479 self.library_scan_rx = None;
480 self.library_scan_inflight = false;
481 match result {
482 Ok(()) => self.on_library_scan_completed_success(),
483 Err(e) => {
484 self.library_scan_pending_invalidate = None;
485 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
486 lib.set_metadata_footer(Some(format!("Library scan failed: {e}")));
487 } else {
488 self.global_error = Some(format!("Library scan failed: {e}"));
489 }
490 }
491 }
492 }
493 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
494 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
495 self.library_scan_rx = None;
496 self.library_scan_inflight = false;
497 self.library_scan_pending_invalidate = None;
498 }
499 }
500 }
501
502 fn apply_library_scan_cache_invalidate(&mut self, inv: &ScanCacheInvalidate) {
503 match inv {
504 ScanCacheInvalidate::None => {}
505 ScanCacheInvalidate::Platform(pid) => {
506 self.rom_cache.remove(&RomCacheKey::Platform(*pid));
507 }
508 ScanCacheInvalidate::AllPlatforms => {
509 self.rom_cache.remove_all_platform_entries();
510 if let AppScreen::LibraryBrowse(lib) = &self.screen {
511 if let Some(ref k) = lib.cache_key() {
512 if !matches!(k, RomCacheKey::Platform(_)) {
513 self.rom_cache.remove(k);
514 }
515 }
516 }
517 }
518 }
519 }
520
521 fn on_library_scan_completed_success(&mut self) {
522 let inv = self
523 .library_scan_pending_invalidate
524 .take()
525 .unwrap_or(ScanCacheInvalidate::AllPlatforms);
526 self.apply_library_scan_cache_invalidate(&inv);
527 if matches!(self.screen, AppScreen::LibraryBrowse(_)) {
528 self.force_rom_reload_after_metadata = true;
529 self.spawn_library_metadata_refresh();
530 }
531 }
532
533 fn format_upload_bytes(n: u64) -> String {
534 const KB: u64 = 1024;
535 const MB: u64 = KB * 1024;
536 const GB: u64 = MB * 1024;
537 if n >= GB {
538 format!("{:.2} GiB", n as f64 / GB as f64)
539 } else if n >= MB {
540 format!("{:.2} MiB", n as f64 / MB as f64)
541 } else if n >= KB {
542 format!("{:.1} KiB", n as f64 / KB as f64)
543 } else {
544 format!("{n} B")
545 }
546 }
547
548 fn spawn_library_upload_worker(&mut self, platform_id: u64, path: PathBuf, scan_after: bool) {
549 if self.library_upload_inflight || self.library_scan_inflight {
550 return;
551 }
552 self.library_upload_inflight = true;
553 self.library_upload_progress_rx = None;
554 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
555 lib.set_metadata_footer(Some("Preparing upload…".into()));
556 }
557 let (prog_tx, prog_rx) = tokio::sync::mpsc::unbounded_channel();
558 let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
559 self.library_upload_progress_rx = Some(prog_rx);
560 self.library_upload_done_rx = Some(done_rx);
561 let client = self.client.clone();
562 tokio::spawn(async move {
563 let result: Result<LibraryUploadComplete, String> = async {
564 client
565 .upload_rom(platform_id, &path, move |uploaded, total| {
566 let _ = prog_tx.send((uploaded, total));
567 })
568 .await
569 .map_err(|e| e.to_string())?;
570 Ok(LibraryUploadComplete {
571 platform_id,
572 scan_after,
573 })
574 }
575 .await;
576 let _ = done_tx.send(result);
577 });
578 }
579
580 fn poll_library_upload(&mut self) {
581 if let Some(rx) = &mut self.library_upload_progress_rx {
582 while let Ok((up, tot)) = rx.try_recv() {
583 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
584 lib.set_metadata_footer(Some(format!(
585 "Uploading {} / {}…",
586 Self::format_upload_bytes(up),
587 Self::format_upload_bytes(tot)
588 )));
589 }
590 }
591 }
592
593 let Some(rx) = &mut self.library_upload_done_rx else {
594 return;
595 };
596 match rx.try_recv() {
597 Ok(result) => {
598 self.library_upload_done_rx = None;
599 self.library_upload_progress_rx = None;
600 self.library_upload_inflight = false;
601 match result {
602 Ok(done) => {
603 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
604 if done.scan_after {
605 lib.set_metadata_footer(Some(
606 "Upload complete. Starting library scan…".into(),
607 ));
608 self.spawn_library_rescan_worker(ScanCacheInvalidate::Platform(
609 done.platform_id,
610 ));
611 } else {
612 lib.set_metadata_footer(Some("Upload complete.".into()));
613 }
614 }
615 }
616 Err(e) => {
617 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
618 lib.set_metadata_footer(Some(format!("Upload failed: {e}")));
619 } else {
620 self.global_error = Some(format!("Upload failed: {e}"));
621 }
622 }
623 }
624 }
625 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
626 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
627 self.library_upload_done_rx = None;
628 self.library_upload_progress_rx = None;
629 self.library_upload_inflight = false;
630 }
631 }
632 }
633
634 fn poll_search_load_results(&mut self) {
635 loop {
636 match self.search_load_rx.try_recv() {
637 Ok(done) => {
638 if let AppScreen::Search(ref mut search) = self.screen {
639 match done.event {
640 SearchLoadEvent::Batch(roms) => {
641 search.set_results_for_query(done.query, roms);
642 }
643 SearchLoadEvent::Failed(err) => {
644 search.loading = false;
645 self.global_error = Some(err);
646 }
647 SearchLoadEvent::Complete => {
648 search.loading = false;
649 }
650 }
651 }
652 }
653 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
654 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
655 }
656 }
657 }
658
659 fn spawn_cover_load_worker(&mut self, rom_id: u64, url: String) {
660 if let Some(task) = self.cover_load_task.take() {
661 task.abort();
662 }
663 let tx = self.cover_load_tx.clone();
664 self.cover_load_task = Some(tokio::spawn(async move {
665 let result = async {
666 let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
667 let status = response.status();
668 if !status.is_success() {
669 return Err(format!("HTTP {}", status.as_u16()));
670 }
671 let bytes = response.bytes().await.map_err(|e| e.to_string())?;
672 image::load_from_memory(&bytes).map_err(|e| e.to_string())
673 }
674 .await;
675 let _ = tx.send(CoverLoadDone { rom_id, result });
676 }));
677 }
678
679 fn poll_cover_load_results(&mut self) {
680 loop {
681 match self.cover_load_rx.try_recv() {
682 Ok(done) => {
683 if let AppScreen::GameDetail(detail) = &mut self.screen {
684 if detail.rom.id != done.rom_id {
685 continue;
686 }
687 match done.result {
688 Ok(image) => detail.apply_cover_image(image),
689 Err(err) => detail.apply_cover_error(format!(
690 "Cover failed: {}",
691 crate::tui::utils::truncate(&err, 120)
692 )),
693 }
694 }
695 }
696 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
697 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
698 }
699 }
700 }
701
702 fn maybe_start_game_detail_cover_load(&mut self) {
703 let (rom_id, url) = match &mut self.screen {
704 AppScreen::GameDetail(detail) => {
705 if !detail.should_request_cover_load() {
706 return;
707 }
708 detail.set_cover_loading();
709 let Some(url) = detail.cover_last_url.clone() else {
710 return;
711 };
712 (detail.rom.id, url)
713 }
714 _ => return,
715 };
716 self.spawn_cover_load_worker(rom_id, url);
717 }
718
719 fn spawn_save_list_worker(&mut self, rom_id: u64) {
720 if let AppScreen::GameDetail(detail) = &mut self.screen {
721 detail.set_saves_loading();
722 }
723 let client = self.client.clone();
724 let tx = self.save_list_tx.clone();
725 tokio::spawn(async move {
726 let result = async {
727 let value = client
728 .request_json(
729 "GET",
730 "/api/saves",
731 &[("rom_id".to_string(), rom_id.to_string())],
732 None,
733 )
734 .await?;
735 SaveMetadata::from_api_value(value)
736 }
737 .await
738 .map_err(|e| format!("{e:#}"));
739 let _ = tx.send(SaveListDone { rom_id, result });
740 });
741 }
742
743 fn refresh_current_game_saves(&mut self) {
744 if let AppScreen::GameDetail(detail) = &self.screen {
745 self.spawn_save_list_worker(detail.rom.id);
746 }
747 }
748
749 fn poll_save_results(&mut self) {
750 while let Ok(done) = self.save_list_rx.try_recv() {
751 if let AppScreen::GameDetail(detail) = &mut self.screen {
752 if detail.rom.id == done.rom_id {
753 match done.result {
754 Ok(rows) => detail.apply_saves(rows),
755 Err(e) => detail.apply_saves_error(e),
756 }
757 }
758 }
759 }
760 while let Ok(done) = self.save_upload_rx.try_recv() {
761 if let AppScreen::GameDetail(detail) = &mut self.screen {
762 if detail.rom.id == done.rom_id {
763 match done.result {
764 Ok(()) => {
765 detail.message = Some("Save uploaded. Refreshing saves...".into());
766 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
767 self.spawn_save_list_worker(done.rom_id);
768 }
769 Err(e) => {
770 detail.message = Some(format!("Save upload failed: {e}"));
771 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
772 }
773 }
774 }
775 }
776 }
777 while let Ok(done) = self.save_download_rx.try_recv() {
778 if let AppScreen::GameDetail(detail) = &mut self.screen {
779 if detail.rom.id == done.rom_id {
780 match done.result {
781 Ok(path) => {
782 detail.message = Some(format!("Save downloaded: {}", path.display()));
783 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
784 self.spawn_save_list_worker(done.rom_id);
785 }
786 Err(e) => {
787 detail.message = Some(format!("Save download failed: {e}"));
788 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
789 }
790 }
791 }
792 }
793 }
794 }
795
796 fn poll_settings_results(&mut self) {
797 while let Ok(done) = self.device_list_rx.try_recv() {
798 if let AppScreen::Settings(settings) = &mut self.screen {
799 match done.result {
800 Ok(devices) => {
801 settings.set_devices(devices);
802 settings.message = None;
803 }
804 Err(e) => {
805 settings.set_device_error(e.clone());
806 settings.message = Some((format!("Device load failed: {e}"), Color::Red));
807 }
808 }
809 }
810 }
811 while let Ok(done) = self.sync_push_pull_rx.try_recv() {
812 if let AppScreen::Settings(settings) = &mut self.screen {
813 settings.sync_inflight = false;
814 match done.result {
815 Ok(session) => {
816 settings.message = Some((
817 format!("Sync session #{}: {}", session.id, session.status),
818 Color::Green,
819 ));
820 }
821 Err(e) => {
822 settings.message = Some((format!("Sync failed: {e}"), Color::Red));
823 }
824 }
825 }
826 }
827 }
828
829 fn poll_rom_load_results(&mut self) {
830 loop {
831 match self.rom_load_rx.try_recv() {
832 Ok(done) => {
833 if !primary_rom_load_result_is_current(done.gen, self.rom_load_gen) {
834 continue;
835 }
836 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
837 continue;
838 };
839 match done.event {
840 RomLoadEvent::Batch(roms) => {
841 if let Some(ref k) = done.key {
842 self.rom_cache
843 .insert(k.clone(), roms.clone(), done.expected);
844 }
845 lib.set_roms(roms);
846 tracing::debug!(
847 "rom-list-render batch context={} latency_ms={}",
848 done.context,
849 done.started.elapsed().as_millis()
850 );
851 }
852 RomLoadEvent::Failed(e) => {
853 lib.set_metadata_footer(Some(format!("Could not load games: {e}")));
854 lib.set_rom_loading(false);
855 }
856 RomLoadEvent::Complete => {
857 lib.set_rom_loading(false);
858 }
859 }
860 }
861 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
862 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
863 }
864 }
865 }
866
867 fn poll_library_metadata_refresh(&mut self) {
868 let mut batch = Vec::new();
869 let mut disconnected = false;
870 if let Some(rx) = &mut self.library_metadata_rx {
871 loop {
872 match rx.try_recv() {
873 Ok(msg) => batch.push(msg),
874 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
875 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
876 disconnected = true;
877 break;
878 }
879 }
880 }
881 }
882 if disconnected {
883 self.library_metadata_rx = None;
884 }
885 for msg in batch {
886 self.apply_library_metadata_refresh(msg);
887 }
888 }
889
890 fn apply_library_metadata_refresh(&mut self, msg: LibraryMetadataRefreshDone) {
891 if msg.gen != self.library_metadata_refresh_gen {
892 return;
893 }
894 let AppScreen::LibraryBrowse(ref mut lib) = self.screen else {
895 return;
896 };
897
898 let had_cached_lists = !lib.platforms.is_empty() || !lib.collections.is_empty();
899 let live_empty = msg.collections.is_empty();
900 if live_empty && had_cached_lists && !msg.warnings.is_empty() {
901 lib.set_temporary_metadata_footer(
902 "Could not refresh library metadata (keeping cached list).".into(),
903 std::time::Duration::from_secs(3),
904 );
905 self.force_rom_reload_after_metadata = false;
906 return;
907 }
908
909 let old_digest =
910 startup_library_snapshot::build_collection_digest_from_collections(&lib.collections);
911 let digest_changed = old_digest != msg.collection_digest;
912 let update_platforms = !msg.platforms.is_empty();
913 let selection_changed = lib.replace_metadata_preserving_selection(
914 msg.platforms,
915 msg.collections,
916 update_platforms,
917 true,
918 );
919 startup_library_snapshot::save_snapshot(&lib.platforms, &lib.collections);
920
921 let footer = if msg.warnings.is_empty() {
922 if digest_changed {
923 Some("Collection metadata updated.".into())
924 } else {
925 None
926 }
927 } else {
928 let w = msg.warnings.join(" | ");
929 let short: String = if w.chars().count() > 160 {
930 let prefix: String = w.chars().take(157).collect();
931 format!("{prefix}…")
932 } else {
933 w
934 };
935 Some(format!("Partial refresh: {}", short))
936 };
937 lib.set_metadata_footer(footer);
938
939 if selection_changed && lib.list_len() > 0 {
940 lib.clear_roms();
941 let key = lib.cache_key();
942 let expected = lib.expected_rom_count();
943 let req = Self::selected_rom_request_for_library(lib);
944 lib.set_rom_loading(expected > 0);
945 self.deferred_load_roms =
946 Some((key, req, expected, "refresh_selection", Instant::now()));
947 }
948
949 let force_reload = std::mem::take(&mut self.force_rom_reload_after_metadata);
950 if force_reload && lib.list_len() > 0 && !selection_changed {
951 lib.clear_roms();
952 let key = lib.cache_key();
953 let expected = lib.expected_rom_count();
954 let req = Self::selected_rom_request_for_library(lib);
955 lib.set_rom_loading(expected > 0);
956 self.deferred_load_roms =
957 Some((key, req, expected, "post_scan_reload", Instant::now()));
958 }
959
960 self.queue_collection_prefetches_from_screen(1, "refresh_warmup");
961 }
962
963 fn queue_collection_prefetches_from_screen(&mut self, radius: usize, _reason: &'static str) {
964 let AppScreen::LibraryBrowse(ref lib) = self.screen else {
965 return;
966 };
967 for (key, req, expected) in lib.collection_prefetch_candidates(radius) {
968 if self.rom_cache.get_valid(&key, expected).is_some() {
969 continue;
970 }
971 if self.collection_prefetch_queued_keys.contains(&key)
972 || self.collection_prefetch_inflight_keys.contains(&key)
973 {
974 continue;
975 }
976 self.collection_prefetch_queued_keys.insert(key.clone());
977 self.collection_prefetch_queue
978 .push_back((key, req, expected));
979 }
980 }
981
982 fn drive_collection_prefetch_scheduler(&mut self) {
983 const PREFETCH_MAX_INFLIGHT: usize = 2;
984 while self.collection_prefetch_inflight_keys.len() < PREFETCH_MAX_INFLIGHT {
985 let Some((key, req, expected)) = self.collection_prefetch_queue.pop_back() else {
986 break;
987 };
988 self.collection_prefetch_queued_keys.remove(&key);
989 self.collection_prefetch_inflight_keys.insert(key.clone());
990 let tx = self.collection_prefetch_tx.clone();
991 let client = self.client.clone();
992 tokio::spawn(async move {
993 let result = Self::fetch_roms_full(client, req).await;
994 let (roms, warning) = match result {
995 Ok(list) => (Some(list), None),
996 Err(e) => (None, Some(format!("Collection prefetch failed: {e:#}"))),
997 };
998 let _ = tx.send(CollectionPrefetchDone {
999 key,
1000 expected,
1001 roms,
1002 warning,
1003 });
1004 });
1005 }
1006 }
1007
1008 fn poll_collection_prefetch_results(&mut self) {
1009 loop {
1010 match self.collection_prefetch_rx.try_recv() {
1011 Ok(done) => {
1012 self.collection_prefetch_inflight_keys.remove(&done.key);
1013 if let Some(roms) = done.roms {
1014 self.rom_cache.insert(done.key, roms, done.expected);
1015 } else if let Some(warning) = done.warning {
1016 tracing::debug!("{warning}");
1017 }
1018 }
1019 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
1020 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
1021 }
1022 }
1023 }
1024
1025 pub fn set_error(&mut self, err: anyhow::Error) {
1026 self.global_error = Some(format!("{:#}", err));
1027 }
1028
1029 pub async fn run(&mut self) -> Result<()> {
1039 enable_raw_mode()?;
1040 let mut stdout = std::io::stdout();
1041 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
1042 let backend = CrosstermBackend::new(stdout);
1043 let mut terminal = Terminal::new(backend)?;
1044
1045 loop {
1046 self.poll_background_tasks();
1047 if self
1048 .startup_splash
1049 .as_ref()
1050 .is_some_and(|s| s.should_auto_dismiss())
1051 {
1052 self.startup_splash = None;
1053 }
1054 terminal.draw(|f| self.render(f))?;
1057
1058 if let Some(ref mut prompt) = self.startup_update_prompt {
1060 if prompt.updating {
1061 if prompt.status.latest_version == "9.9.9-mock" {
1063 tokio::time::sleep(std::time::Duration::from_secs(2)).await; self.global_error =
1065 Some("Mock update successful! (No files were changed)".into());
1066 self.startup_update_prompt = None;
1067 } else {
1068 let options = crate::update::ApplyUpdateOptions {
1069 show_progress: false,
1070 show_output: false,
1071 no_confirm: true,
1072 target_version_tag: Some(prompt.status.release_tag.clone()),
1073 };
1074 match crate::update::apply_update(None, options).await {
1075 Ok(crate::update::ApplyUpdateOutcome::Updated(version)) => {
1076 self.global_notice = Some(format!(
1077 "Updated to {version}. Restart romm-cli to use the new version."
1078 ));
1079 }
1080 Ok(crate::update::ApplyUpdateOutcome::UpToDate(version)) => {
1081 self.global_notice =
1082 Some(format!("Already up to date (`{version}`)."));
1083 }
1084 Err(err) => {
1085 self.global_error = Some(format!("Update failed: {err:#}"));
1086 }
1087 }
1088 self.startup_update_prompt = None;
1089 }
1090 continue;
1091 }
1092 }
1093
1094 if event::poll(Duration::from_millis(100))? {
1097 if let Event::Key(key_event) = event::read()? {
1098 if Self::is_force_quit_key(&key_event) {
1099 break;
1100 }
1101 if key_event.kind == KeyEventKind::Press
1102 && key_event.modifiers.contains(KeyModifiers::CONTROL)
1103 && matches!(key_event.code, KeyCode::Char('r') | KeyCode::Char('R'))
1104 {
1105 if let AppScreen::LibraryBrowse(ref lib) = self.screen {
1106 if !lib.any_search_bar_open()
1107 && !lib.any_upload_prompt_open()
1108 && !self.library_upload_inflight
1109 && !self.library_scan_inflight
1110 {
1111 self.spawn_library_rescan_worker(ScanCacheInvalidate::AllPlatforms);
1112 }
1113 }
1114 continue;
1115 }
1116 if key_event.kind == KeyEventKind::Press
1117 && key_event.modifiers.contains(KeyModifiers::CONTROL)
1118 && matches!(key_event.code, KeyCode::Char('u') | KeyCode::Char('U'))
1119 {
1120 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1121 if lib.any_upload_prompt_open() {
1122 lib.close_upload_prompt();
1123 } else if !lib.any_search_bar_open()
1124 && !self.library_upload_inflight
1125 && !self.library_scan_inflight
1126 {
1127 if lib.subsection
1128 == super::screens::library_browse::LibrarySubsection::ByConsole
1129 {
1130 lib.open_upload_prompt();
1131 } else {
1132 lib.set_metadata_footer(Some(
1133 "Upload requires Consoles view — press t".into(),
1134 ));
1135 }
1136 }
1137 }
1138 continue;
1139 }
1140 if key_event.kind == KeyEventKind::Press
1141 && self.handle_key_event(&key_event).await?
1142 {
1143 break;
1144 }
1145 }
1146 }
1147
1148 if let Some((key, req, expected, context, started)) = self.deferred_load_roms.take() {
1152 if let Some(ref k) = key {
1154 if let Some(cached) = self.rom_cache.get_valid(k, expected) {
1155 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1156 lib.set_roms(cached.clone());
1157 lib.set_rom_loading(false);
1158 tracing::debug!(
1159 "rom-list-render context={} latency_ms={} (cache_hit)",
1160 context,
1161 started.elapsed().as_millis()
1162 );
1163 }
1164 continue;
1165 }
1166 }
1167
1168 if started.elapsed() < std::time::Duration::from_millis(250) {
1170 self.deferred_load_roms = Some((key, req, expected, context, started));
1172 continue;
1173 }
1174
1175 self.rom_load_gen = self.rom_load_gen.saturating_add(1);
1176 let gen = self.rom_load_gen;
1177 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1178 lib.set_rom_loading(expected > 0);
1179 }
1180 if expected == 0 {
1181 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1182 lib.set_rom_loading(false);
1183 }
1184 continue;
1185 }
1186
1187 let Some(r) = req else {
1188 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1189 lib.set_rom_loading(false);
1190 }
1191 continue;
1192 };
1193 let client = self.client.clone();
1194 let tx = self.rom_load_tx.clone();
1195
1196 if let Some(task) = self.rom_load_task.take() {
1197 task.abort();
1198 }
1199
1200 self.rom_load_task = Some(tokio::spawn(async move {
1201 let mut req = r;
1202 let mut aggregated: Option<RomList> = None;
1203
1204 loop {
1205 match client.call(&req).await {
1206 Ok(mut batch) => {
1207 if let Some(ref mut all) = aggregated {
1208 if batch.items.is_empty() {
1209 break;
1210 }
1211 all.items.append(&mut batch.items);
1212 let _ = tx.send(RomLoadDone {
1213 gen,
1214 key: key.clone(),
1215 expected,
1216 event: RomLoadEvent::Batch(all.clone()),
1217 context,
1218 started,
1219 });
1220 if all.items.len() as u64 >= all.total {
1221 break;
1222 }
1223 req.offset = Some(all.items.len() as u32);
1224 } else {
1225 let loaded = batch.items.len() as u64;
1226 let total = batch.total;
1227 let _ = tx.send(RomLoadDone {
1228 gen,
1229 key: key.clone(),
1230 expected,
1231 event: RomLoadEvent::Batch(batch.clone()),
1232 context,
1233 started,
1234 });
1235 req.offset = Some(loaded as u32);
1236 aggregated = Some(batch);
1237 if loaded >= total {
1238 break;
1239 }
1240 }
1241 }
1242 Err(e) => {
1243 let _ = tx.send(RomLoadDone {
1244 gen,
1245 key: key.clone(),
1246 expected,
1247 event: RomLoadEvent::Failed(format!("{e:#}")),
1248 context,
1249 started,
1250 });
1251 return;
1252 }
1253 }
1254 if let Some(ref all) = aggregated {
1256 if all.items.len() >= 20000 {
1257 break;
1258 }
1259 }
1260 }
1261
1262 let _ = tx.send(RomLoadDone {
1263 gen,
1264 key,
1265 expected,
1266 event: RomLoadEvent::Complete,
1267 context,
1268 started,
1269 });
1270 }));
1271 }
1272 }
1273
1274 disable_raw_mode()?;
1275 execute!(
1276 terminal.backend_mut(),
1277 LeaveAlternateScreen,
1278 DisableMouseCapture
1279 )?;
1280 terminal.show_cursor()?;
1281 Ok(())
1282 }
1283
1284 async fn fetch_roms_full(client: RommClient, req: GetRoms) -> Result<RomList> {
1288 let mut roms = client.call(&req).await?;
1289 let total = roms.total;
1290 let ceiling = 20000;
1291 while (roms.items.len() as u64) < total && (roms.items.len() as u64) < ceiling {
1292 let mut next_req = req.clone();
1293 next_req.offset = Some(roms.items.len() as u32);
1294 let next_batch = client.call(&next_req).await?;
1295 if next_batch.items.is_empty() {
1296 break;
1297 }
1298 roms.items.extend(next_batch.items);
1299 }
1300 Ok(roms)
1301 }
1302
1303 pub async fn handle_key_event(&mut self, key: &KeyEvent) -> Result<bool> {
1308 if key.kind != KeyEventKind::Press {
1309 return Ok(false);
1310 }
1311
1312 if self.startup_update_prompt.is_some() {
1313 return self.handle_startup_update_prompt(key).await;
1314 }
1315
1316 if self.global_error.is_some() || self.global_notice.is_some() {
1317 if key.code == KeyCode::Esc || key.code == KeyCode::Enter {
1318 self.global_error = None;
1319 self.global_notice = None;
1320 }
1321 return Ok(false);
1322 }
1323
1324 if self.startup_splash.is_some() {
1325 self.startup_splash = None;
1326 return Ok(false);
1327 }
1328
1329 if self.show_keyboard_help {
1330 if matches!(
1331 key.code,
1332 KeyCode::Esc | KeyCode::Enter | KeyCode::F(1) | KeyCode::Char('?')
1333 ) {
1334 self.show_keyboard_help = false;
1335 }
1336 return Ok(false);
1337 }
1338
1339 if key.code == KeyCode::F(1) {
1340 self.show_keyboard_help = true;
1341 return Ok(false);
1342 }
1343 if key.code == KeyCode::Char('?') && self.allows_global_question_help() {
1344 self.show_keyboard_help = true;
1345 return Ok(false);
1346 }
1347
1348 if key.code == KeyCode::Char('d') && !self.blocks_global_d_shortcut() {
1350 self.toggle_download_screen();
1351 return Ok(false);
1352 }
1353
1354 match &self.screen {
1355 AppScreen::MainMenu(_) => self.handle_main_menu(key).await,
1356 AppScreen::LibraryBrowse(_) => self.handle_library_browse(key).await,
1357 AppScreen::Search(_) => self.handle_search(key).await,
1358 AppScreen::Settings(_) => self.handle_settings(key).await,
1359 AppScreen::Browse(_) => self.handle_browse(key),
1360 AppScreen::Execute(_) => self.handle_execute(key).await,
1361 AppScreen::Result(_) => self.handle_result(key),
1362 AppScreen::ResultDetail(_) => self.handle_result_detail(key),
1363 AppScreen::GameDetail(_) => self.handle_game_detail(key),
1364 AppScreen::ExtrasPicker(_) => self.handle_extras_picker(key),
1365 AppScreen::Download(_) => self.handle_download(key),
1366 AppScreen::SetupWizard(_) => self.handle_setup_wizard(key).await,
1367 }
1368 }
1369
1370 async fn handle_startup_update_prompt(&mut self, key: &KeyEvent) -> Result<bool> {
1371 let Some(ref mut prompt) = self.startup_update_prompt else {
1372 return Ok(false);
1373 };
1374 if prompt.updating {
1375 return Ok(false); }
1377
1378 match key.code {
1379 KeyCode::Char('u')
1380 | KeyCode::Char('U')
1381 | KeyCode::Char('y')
1382 | KeyCode::Char('Y')
1383 | KeyCode::Enter => {
1384 prompt.updating = true;
1385 Ok(true)
1388 }
1389 KeyCode::Char('c') | KeyCode::Char('C') => {
1390 if let Err(err) = crate::update::open_changelog_in_browser() {
1391 self.global_error = Some(format!("Could not open changelog: {err:#}"));
1392 } else {
1393 self.global_error =
1394 Some(format!("Opened changelog: {}", prompt.status.changelog_url));
1395 }
1396 Ok(false)
1397 }
1398 KeyCode::Esc
1399 | KeyCode::Char('s')
1400 | KeyCode::Char('S')
1401 | KeyCode::Char('n')
1402 | KeyCode::Char('N')
1403 | KeyCode::Char('q')
1404 | KeyCode::Char('Q') => {
1405 self.startup_update_prompt = None;
1406 Ok(false)
1407 }
1408 _ => Ok(false),
1409 }
1410 }
1411
1412 fn toggle_download_screen(&mut self) {
1415 let current =
1416 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
1417 match current {
1418 AppScreen::Download(_) => {
1419 self.screen = self
1420 .screen_before_download
1421 .take()
1422 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
1423 }
1424 other => {
1425 self.screen_before_download = Some(other);
1426 self.screen = AppScreen::Download(DownloadScreen::new(
1427 self.downloads.shared(),
1428 self.downloads.shared_extras(),
1429 ));
1430 }
1431 }
1432 }
1433
1434 fn handle_download(&mut self, key: &KeyEvent) -> Result<bool> {
1435 if key.code == KeyCode::Esc || key.code == KeyCode::Char('d') {
1436 self.screen = self
1437 .screen_before_download
1438 .take()
1439 .unwrap_or_else(|| AppScreen::MainMenu(MainMenuScreen::new()));
1440 }
1441 Ok(false)
1442 }
1443
1444 async fn handle_main_menu(&mut self, key: &KeyEvent) -> Result<bool> {
1447 let menu = match &mut self.screen {
1448 AppScreen::MainMenu(m) => m,
1449 _ => return Ok(false),
1450 };
1451 match key.code {
1452 KeyCode::Up | KeyCode::Char('k') => menu.previous(),
1453 KeyCode::Down | KeyCode::Char('j') => menu.next(),
1454 KeyCode::Enter => match menu.selected {
1455 0 => {
1456 let start = Instant::now();
1457 let snap = startup_library_snapshot::load_snapshot();
1458 let (platforms, collections, from_disk) = match snap {
1459 Some(s) => (s.platforms, s.collections, true),
1460 None => (Vec::new(), Vec::new(), false),
1461 };
1462 let mut lib = LibraryBrowseScreen::new(platforms, collections);
1463 if from_disk && lib.list_len() > 0 {
1464 lib.set_metadata_footer(Some(
1465 "Refreshing library metadata in background…".into(),
1466 ));
1467 } else if lib.list_len() == 0 {
1468 lib.set_metadata_footer(Some("Loading library metadata…".into()));
1469 }
1470 if lib.list_len() > 0 {
1471 let key = lib.cache_key();
1472 let expected = lib.expected_rom_count();
1473 let req = Self::selected_rom_request_for_library(&lib);
1474 lib.set_rom_loading(expected > 0);
1475 self.deferred_load_roms = Some((
1476 key,
1477 req,
1478 expected,
1479 "startup_first_selection",
1480 Instant::now(),
1481 ));
1482 }
1483 self.screen = AppScreen::LibraryBrowse(lib);
1484 self.spawn_library_metadata_refresh();
1485 tracing::debug!(
1486 "library-open latency_ms={} snapshot_hit={}",
1487 start.elapsed().as_millis(),
1488 from_disk
1489 );
1490 }
1491 1 => self.screen = AppScreen::Search(SearchScreen::new()),
1492 2 => {
1493 self.screen_before_download = Some(AppScreen::MainMenu(MainMenuScreen::new()));
1494 self.screen = AppScreen::Download(DownloadScreen::new(
1495 self.downloads.shared(),
1496 self.downloads.shared_extras(),
1497 ));
1498 }
1499 3 => {
1500 self.screen = AppScreen::Settings(SettingsScreen::new(
1501 &self.config,
1502 self.server_version.as_deref(),
1503 self.save_sync_compat.clone(),
1504 ))
1505 }
1506 4 => return Ok(true),
1507 _ => {}
1508 },
1509 KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
1510 _ => {}
1511 }
1512 Ok(false)
1513 }
1514
1515 async fn handle_library_browse(&mut self, key: &KeyEvent) -> Result<bool> {
1518 use super::path_picker::PathPickerEvent;
1519 use super::screens::library_browse::{LibrarySearchMode, LibraryViewMode};
1520
1521 if let AppScreen::LibraryBrowse(ref mut lib) = self.screen {
1522 if lib.upload_prompt.is_some() {
1523 if let Some(up) = lib.upload_prompt.as_mut() {
1524 if key.code == KeyCode::Esc {
1525 lib.close_upload_prompt();
1526 return Ok(false);
1527 }
1528 if key.modifiers.contains(KeyModifiers::CONTROL)
1529 && matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S'))
1530 {
1531 up.scan_after = !up.scan_after;
1532 return Ok(false);
1533 }
1534 match up.picker.handle_key(key) {
1535 PathPickerEvent::Confirmed(path) => {
1536 let scan_after = up.scan_after;
1537 if !Path::new(&path).is_file() {
1538 lib.set_metadata_footer(Some(format!(
1539 "Not a file: {}",
1540 path.display()
1541 )));
1542 return Ok(false);
1543 }
1544 let Some(pid) = lib.selected_platform_id() else {
1545 lib.set_metadata_footer(Some(
1546 "Select a console before uploading.".into(),
1547 ));
1548 return Ok(false);
1549 };
1550 lib.close_upload_prompt();
1551 self.spawn_library_upload_worker(pid, path, scan_after);
1552 }
1553 PathPickerEvent::None => {}
1554 }
1555 }
1556 return Ok(false);
1557 }
1558 }
1559
1560 if self.library_upload_inflight {
1561 return Ok(false);
1562 }
1563
1564 let lib = match &mut self.screen {
1565 AppScreen::LibraryBrowse(l) => l,
1566 _ => return Ok(false),
1567 };
1568
1569 if lib.view_mode == LibraryViewMode::List {
1571 if let Some(mode) = lib.list_search.mode {
1572 let old_key = lib.cache_key();
1573 match key.code {
1574 KeyCode::Esc => lib.clear_list_search(),
1575 KeyCode::Backspace => lib.delete_list_search_char(),
1576 KeyCode::Char(c) => lib.add_list_search_char(c),
1577 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.list_jump_match(true),
1578 KeyCode::Enter => lib.commit_list_filter_bar(),
1579 _ => {}
1580 }
1581 let new_key = lib.cache_key();
1582 if old_key != new_key && lib.list_len() > 0 {
1583 lib.clear_roms();
1584 let expected = lib.expected_rom_count();
1585 if expected > 0 {
1586 let req = Self::selected_rom_request_for_library(lib);
1587 lib.set_rom_loading(true);
1588 self.deferred_load_roms =
1589 Some((new_key, req, expected, "search_filter", Instant::now()));
1590 } else {
1591 lib.set_rom_loading(false);
1592 self.deferred_load_roms = None;
1593 }
1594 }
1595 return Ok(false);
1596 }
1597 }
1598
1599 if lib.view_mode == LibraryViewMode::Roms {
1601 if let Some(mode) = lib.rom_search.mode {
1602 match key.code {
1603 KeyCode::Esc => lib.clear_rom_search(),
1604 KeyCode::Backspace => lib.delete_rom_search_char(),
1605 KeyCode::Char(c) => lib.add_rom_search_char(c),
1606 KeyCode::Tab if mode == LibrarySearchMode::Jump => lib.jump_rom_match(true),
1607 KeyCode::Enter => lib.commit_rom_filter_bar(),
1608 _ => {}
1609 }
1610 return Ok(false);
1611 }
1612 }
1613
1614 match key.code {
1615 KeyCode::Up | KeyCode::Char('k') => {
1616 if lib.view_mode == LibraryViewMode::List {
1617 lib.list_previous();
1618 if lib.list_len() > 0 {
1619 lib.clear_roms(); let key = lib.cache_key();
1621 let expected = lib.expected_rom_count();
1622 if expected > 0 {
1623 let req = Self::selected_rom_request_for_library(lib);
1624 lib.set_rom_loading(true);
1625 self.deferred_load_roms =
1626 Some((key, req, expected, "list_move_up", Instant::now()));
1627 } else {
1628 lib.set_rom_loading(false);
1629 self.deferred_load_roms = None;
1630 }
1631 if lib.subsection
1632 == super::screens::library_browse::LibrarySubsection::ByCollection
1633 {
1634 tracing::debug!("collections-selection move=up expected={expected}");
1635 self.queue_collection_prefetches_from_screen(1, "move_up");
1636 }
1637 }
1638 } else {
1639 lib.rom_previous();
1640 }
1641 }
1642 KeyCode::Down | KeyCode::Char('j') => {
1643 if lib.view_mode == LibraryViewMode::List {
1644 lib.list_next();
1645 if lib.list_len() > 0 {
1646 lib.clear_roms(); let key = lib.cache_key();
1648 let expected = lib.expected_rom_count();
1649 if expected > 0 {
1650 let req = Self::selected_rom_request_for_library(lib);
1651 lib.set_rom_loading(true);
1652 self.deferred_load_roms =
1653 Some((key, req, expected, "list_move_down", Instant::now()));
1654 } else {
1655 lib.set_rom_loading(false);
1656 self.deferred_load_roms = None;
1657 }
1658 if lib.subsection
1659 == super::screens::library_browse::LibrarySubsection::ByCollection
1660 {
1661 tracing::debug!("collections-selection move=down expected={expected}");
1662 self.queue_collection_prefetches_from_screen(1, "move_down");
1663 }
1664 }
1665 } else {
1666 lib.rom_next();
1667 }
1668 }
1669 KeyCode::Left | KeyCode::Char('h') if lib.view_mode == LibraryViewMode::Roms => {
1670 lib.back_to_list();
1671 }
1672 KeyCode::Right | KeyCode::Char('l') => lib.switch_view(),
1673 KeyCode::Tab => {
1674 if lib.view_mode == LibraryViewMode::List {
1675 lib.switch_view();
1676 } else {
1677 lib.switch_view(); }
1679 }
1680 KeyCode::Char('/') => match lib.view_mode {
1681 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Filter),
1682 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Filter),
1683 },
1684 KeyCode::Char('f') => match lib.view_mode {
1685 LibraryViewMode::List => lib.enter_list_search(LibrarySearchMode::Jump),
1686 LibraryViewMode::Roms => lib.enter_rom_search(LibrarySearchMode::Jump),
1687 },
1688 KeyCode::Enter => {
1689 if lib.view_mode == LibraryViewMode::List {
1690 lib.switch_view();
1691 } else if let Some((primary, others)) = lib.get_selected_group() {
1692 let lib_screen = std::mem::replace(
1693 &mut self.screen,
1694 AppScreen::MainMenu(MainMenuScreen::new()),
1695 );
1696 if let AppScreen::LibraryBrowse(l) = lib_screen {
1697 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1698 primary,
1699 others,
1700 GameDetailPrevious::Library(Box::new(l)),
1701 self.downloads.shared(),
1702 )));
1703 self.maybe_start_game_detail_cover_load();
1704 self.refresh_current_game_saves();
1705 }
1706 }
1707 }
1708 KeyCode::Char('t') => {
1709 lib.switch_subsection();
1710 if lib.view_mode == LibraryViewMode::List && lib.list_len() > 0 {
1713 let key = lib.cache_key();
1714 let expected = lib.expected_rom_count();
1715 if expected > 0 {
1716 let req = Self::selected_rom_request_for_library(lib);
1717 lib.set_rom_loading(true);
1718 self.deferred_load_roms =
1719 Some((key, req, expected, "switch_subsection", Instant::now()));
1720 } else {
1721 lib.set_rom_loading(false);
1722 self.deferred_load_roms = None;
1723 }
1724 }
1725 if lib.subsection == super::screens::library_browse::LibrarySubsection::ByCollection
1726 {
1727 tracing::debug!("collections-subsection entered");
1728 self.queue_collection_prefetches_from_screen(1, "enter_collections");
1729 }
1730 }
1731 KeyCode::Esc => {
1732 if lib.view_mode == LibraryViewMode::Roms {
1733 if lib.rom_search.filter_browsing {
1734 lib.clear_rom_search();
1735 } else {
1736 lib.back_to_list();
1737 }
1738 } else if lib.list_search.filter_browsing {
1739 lib.clear_list_search();
1740 } else {
1741 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1742 }
1743 }
1744 KeyCode::Char('q') => return Ok(true),
1745 _ => {}
1746 }
1747 Ok(false)
1748 }
1749
1750 async fn handle_search(&mut self, key: &KeyEvent) -> Result<bool> {
1753 let search = match &mut self.screen {
1754 AppScreen::Search(s) => s,
1755 _ => return Ok(false),
1756 };
1757 match key.code {
1758 KeyCode::Backspace => search.delete_char(),
1759 KeyCode::Left => search.cursor_left(),
1760 KeyCode::Right => search.cursor_right(),
1761 KeyCode::Up => search.previous(),
1762 KeyCode::Down => search.next(),
1763 KeyCode::Char(c) => search.add_char(c),
1764 KeyCode::Enter => {
1765 if search.query.is_empty() {
1766 } else if search.result_groups.is_some() && search.results_match_current_query() {
1768 if let Some((primary, others)) = search.get_selected_group() {
1769 let prev = std::mem::replace(
1770 &mut self.screen,
1771 AppScreen::MainMenu(MainMenuScreen::new()),
1772 );
1773 if let AppScreen::Search(s) = prev {
1774 self.screen = AppScreen::GameDetail(Box::new(GameDetailScreen::new(
1775 primary,
1776 others,
1777 GameDetailPrevious::Search(s),
1778 self.downloads.shared(),
1779 )));
1780 self.maybe_start_game_detail_cover_load();
1781 self.refresh_current_game_saves();
1782 }
1783 }
1784 } else {
1785 let query = search.query.clone();
1786 let req = GetRoms {
1787 search_term: Some(query.clone()),
1788 limit: Some(50),
1789 ..Default::default()
1790 };
1791 search.loading = true;
1792 if let Some(task) = self.search_load_task.take() {
1793 task.abort();
1794 }
1795 let client = self.client.clone();
1796 let tx = self.search_load_tx.clone();
1797 self.search_load_task = Some(tokio::spawn(async move {
1798 let mut req = req;
1799 let mut aggregated: Option<RomList> = None;
1800
1801 loop {
1802 match client.call(&req).await {
1803 Ok(mut batch) => {
1804 if let Some(ref mut all) = aggregated {
1805 if batch.items.is_empty() {
1806 break;
1807 }
1808 all.items.append(&mut batch.items);
1809 let _ = tx.send(SearchLoadDone {
1810 query: query.clone(),
1811 event: SearchLoadEvent::Batch(all.clone()),
1812 });
1813 if all.items.len() as u64 >= all.total {
1814 break;
1815 }
1816 req.offset = Some(all.items.len() as u32);
1817 } else {
1818 let loaded = batch.items.len() as u64;
1819 let total = batch.total;
1820 let _ = tx.send(SearchLoadDone {
1821 query: query.clone(),
1822 event: SearchLoadEvent::Batch(batch.clone()),
1823 });
1824 req.offset = Some(loaded as u32);
1825 aggregated = Some(batch);
1826 if loaded >= total {
1827 break;
1828 }
1829 }
1830 }
1831 Err(e) => {
1832 let _ = tx.send(SearchLoadDone {
1833 query: query.clone(),
1834 event: SearchLoadEvent::Failed(format!("{e:#}")),
1835 });
1836 return;
1837 }
1838 }
1839 }
1840
1841 let _ = tx.send(SearchLoadDone {
1842 query,
1843 event: SearchLoadEvent::Complete,
1844 });
1845 }));
1846 }
1847 }
1848 KeyCode::Esc => {
1849 if search.results.is_some() {
1850 search.clear_results();
1851 } else {
1852 self.screen = AppScreen::MainMenu(MainMenuScreen::new());
1853 }
1854 }
1855 _ => {}
1856 }
1857 Ok(false)
1858 }
1859
1860 async fn refresh_settings_server_version(&mut self) -> Result<()> {
1863 let (base_url, download_dir, use_https, verbose, auth) = {
1864 let settings = match &self.screen {
1865 AppScreen::Settings(s) => s,
1866 _ => return Ok(()),
1867 };
1868 let mut base_url = normalize_romm_origin(settings.base_url.trim());
1869 if settings.use_https && base_url.starts_with("http://") {
1870 base_url = base_url.replace("http://", "https://");
1871 }
1872 if !settings.use_https && base_url.starts_with("https://") {
1873 base_url = base_url.replace("https://", "http://");
1874 }
1875 (
1876 base_url,
1877 settings.download_dir.clone(),
1878 settings.use_https,
1879 self.client.verbose(),
1880 self.config.auth.clone(),
1881 )
1882 };
1883 let cfg = Config {
1884 base_url,
1885 download_dir,
1886 use_https,
1887 auth,
1888 extras_defaults: self.config.extras_defaults.clone(),
1889 save_sync: self.config.save_sync.clone(),
1890 };
1891 let client = match RommClient::new(&cfg, verbose) {
1892 Ok(c) => c,
1893 Err(_) => {
1894 if let AppScreen::Settings(s) = &mut self.screen {
1895 s.server_version = "unavailable (invalid URL or client error)".to_string();
1896 self.server_version = None;
1897 }
1898 return Ok(());
1899 }
1900 };
1901 let ver = client.rom_server_version_from_heartbeat().await;
1902 if let AppScreen::Settings(s) = &mut self.screen {
1903 match ver {
1904 Some(v) => {
1905 s.server_version = v.clone();
1906 self.server_version = Some(v);
1907 }
1908 None => {
1909 s.server_version = "unavailable (heartbeat failed)".to_string();
1910 self.server_version = None;
1911 }
1912 }
1913 }
1914 Ok(())
1915 }
1916
1917 async fn handle_settings(&mut self, key: &KeyEvent) -> Result<bool> {
1918 use super::path_picker::PathPickerEvent;
1919 use crate::core::download::validate_configured_download_directory;
1920
1921 let settings = match &mut self.screen {
1922 AppScreen::Settings(s) => s,
1923 _ => return Ok(false),
1924 };
1925
1926 if let Some((kind, ref mut picker)) = settings.path_picker {
1927 if key.code == KeyCode::Esc {
1928 settings.path_picker = None;
1929 return Ok(false);
1930 }
1931 match picker.handle_key(key) {
1932 PathPickerEvent::Confirmed(p) => {
1933 match validate_configured_download_directory(p.to_string_lossy().as_ref()) {
1934 Ok(canonical) => {
1935 if kind == super::screens::settings::SettingsPickerKind::RomsDir {
1936 settings.download_dir = canonical.display().to_string();
1937 settings.message = Some((
1938 "ROMs directory updated (press S to save)".to_string(),
1939 Color::Green,
1940 ));
1941 } else {
1942 settings.save_dir = canonical.display().to_string();
1943 settings.message = Some((
1944 "Save directory updated (press S to save)".to_string(),
1945 Color::Green,
1946 ));
1947 }
1948 settings.path_picker = None;
1949 }
1950 Err(e) => {
1951 settings.message =
1952 Some((format!("Invalid ROMs directory: {e:#}"), Color::Red));
1953 }
1954 }
1955 }
1956 PathPickerEvent::None => {}
1957 }
1958 return Ok(false);
1959 }
1960
1961 if settings.device_picker_open {
1962 match key.code {
1963 KeyCode::Esc => {
1964 settings.device_picker_open = false;
1965 settings.device_picker_loading = false;
1966 }
1967 KeyCode::Up | KeyCode::Char('k') => settings.device_previous(),
1968 KeyCode::Down | KeyCode::Char('j') => settings.device_next(),
1969 KeyCode::Enter => settings.confirm_device(),
1970 _ => {}
1971 }
1972 return Ok(false);
1973 }
1974
1975 if settings.confirm.is_some() {
1976 match key.code {
1977 KeyCode::Enter => match settings.confirm.take().unwrap() {
1978 super::screens::settings::SettingsConfirm::Reset => {
1979 let _ = crate::config::reset_all_settings();
1980 settings.message = Some((
1981 "Settings deleted. Please restart romm-cli.".to_string(),
1982 Color::Yellow,
1983 ));
1984 }
1985 super::screens::settings::SettingsConfirm::ClearCache => {
1986 match crate::core::cache::RomCache::clear_file() {
1987 Ok(true) => {
1988 self.rom_cache = crate::core::cache::RomCache::load();
1989 settings.message =
1990 Some(("ROM cache cleared.".to_string(), Color::Green));
1991 }
1992 Ok(false) => {
1993 settings.message = Some((
1994 "ROM cache file does not exist.".to_string(),
1995 Color::Yellow,
1996 ));
1997 }
1998 Err(e) => {
1999 settings.message =
2000 Some((format!("Failed to clear cache: {e}"), Color::Red));
2001 }
2002 }
2003 }
2004 },
2005 KeyCode::Esc => {
2006 settings.confirm = None;
2007 }
2008 _ => {}
2009 }
2010 return Ok(false);
2011 }
2012
2013 if settings.editing {
2014 match key.code {
2015 KeyCode::Enter => {
2016 let row = settings.selected_row();
2017 settings.save_edit();
2018 if row == SettingsRow::BaseUrl {
2019 self.refresh_settings_server_version().await?;
2020 }
2021 }
2022 KeyCode::Esc => settings.cancel_edit(),
2023 KeyCode::Backspace => settings.delete_char(),
2024 KeyCode::Left => settings.move_cursor_left(),
2025 KeyCode::Right => settings.move_cursor_right(),
2026 KeyCode::Char(c) => settings.add_char(c),
2027 _ => {}
2028 }
2029 return Ok(false);
2030 }
2031
2032 match key.code {
2033 KeyCode::Up | KeyCode::Char('k') => settings.previous(),
2034 KeyCode::Down | KeyCode::Char('j') => settings.next(),
2035 KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => settings.next_tab(),
2036 KeyCode::Left | KeyCode::Char('h') | KeyCode::BackTab => settings.previous_tab(),
2037 KeyCode::Enter => {
2038 let row = settings.selected_row();
2039 if row == SettingsRow::Auth {
2040 self.screen =
2041 AppScreen::SetupWizard(Box::new(SetupWizard::new_auth_only(&self.config)));
2042 } else if row == SettingsRow::SyncDevice {
2043 if !settings.save_sync_supported() {
2044 settings.set_save_sync_unsupported_message();
2045 return Ok(false);
2046 }
2047 settings.enter_edit();
2048 let client = self.client.clone();
2049 let tx = self.device_list_tx.clone();
2050 tokio::spawn(async move {
2051 let result = client
2052 .call(&ListDevices)
2053 .await
2054 .map_err(|e| format!("{e:#}"));
2055 let _ = tx.send(DeviceListDone { result });
2056 });
2057 } else if row == SettingsRow::SyncNow {
2058 if !settings.save_sync_supported() {
2059 settings.set_save_sync_unsupported_message();
2060 return Ok(false);
2061 }
2062 if settings.sync_inflight {
2063 return Ok(false);
2064 }
2065 let Some(device_id) = settings.sync_device_id.clone() else {
2066 settings.message =
2067 Some(("Choose a Sync Device first".to_string(), Color::Yellow));
2068 return Ok(false);
2069 };
2070 settings.sync_inflight = true;
2071 settings.message =
2072 Some(("Sync Saves Now running...".to_string(), Color::Yellow));
2073 let client = self.client.clone();
2074 let tx = self.sync_push_pull_tx.clone();
2075 tokio::spawn(async move {
2076 let result = client
2077 .call(&TriggerPushPull { device_id })
2078 .await
2079 .map_err(|e| format!("{e:#}"));
2080 let _ = tx.send(SyncPushPullDone { result });
2081 });
2082 } else {
2083 let toggle_https = row == SettingsRow::UseHttps;
2084 settings.enter_edit();
2085 if toggle_https {
2086 self.refresh_settings_server_version().await?;
2087 }
2088 }
2089 }
2090 KeyCode::Char('s' | 'S') => {
2091 use crate::config::persist_user_config;
2093 let auth = auth_for_persist_merge(self.config.auth.clone());
2094 let cfg = Config {
2095 base_url: settings.base_url.clone(),
2096 download_dir: settings.download_dir.clone(),
2097 use_https: settings.use_https,
2098 auth,
2099 extras_defaults: ExtrasDefaults {
2100 include_related_roms: settings.extras_include_related_roms,
2101 include_cover: settings.extras_include_cover,
2102 include_manual: settings.extras_include_manual,
2103 },
2104 save_sync: SaveSyncConfig {
2105 save_dir: Some(settings.save_dir.clone()),
2106 device_id: settings.sync_device_id.clone(),
2107 },
2108 };
2109 if let Err(e) = persist_user_config(&cfg) {
2110 settings.message = Some((format!("Error saving: {e}"), Color::Red));
2111 } else {
2112 settings.message = Some(("Saved to config.json".to_string(), Color::Green));
2113 self.config.base_url = cfg.base_url.clone();
2115 self.config.download_dir = cfg.download_dir.clone();
2116 self.config.use_https = cfg.use_https;
2117 self.config.extras_defaults = cfg.extras_defaults.clone();
2118 self.config.save_sync = cfg.save_sync.clone();
2119 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
2121 self.client = new_client;
2122 }
2123 }
2124 }
2125 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
2126 KeyCode::Char('q') => return Ok(true),
2127 _ => {}
2128 }
2129 Ok(false)
2130 }
2131
2132 fn handle_browse(&mut self, key: &KeyEvent) -> Result<bool> {
2135 use super::screens::browse::ViewMode;
2136
2137 let browse = match &mut self.screen {
2138 AppScreen::Browse(b) => b,
2139 _ => return Ok(false),
2140 };
2141 match key.code {
2142 KeyCode::Up | KeyCode::Char('k') => browse.previous(),
2143 KeyCode::Down | KeyCode::Char('j') => browse.next(),
2144 KeyCode::Left | KeyCode::Char('h') if browse.view_mode == ViewMode::Endpoints => {
2145 browse.switch_view();
2146 }
2147 KeyCode::Right | KeyCode::Char('l') if browse.view_mode == ViewMode::Sections => {
2148 browse.switch_view();
2149 }
2150 KeyCode::Tab => browse.switch_view(),
2151 KeyCode::Enter => {
2152 if browse.view_mode == ViewMode::Endpoints {
2153 if let Some(ep) = browse.get_selected_endpoint() {
2154 self.screen = AppScreen::Execute(ExecuteScreen::new(ep.clone()));
2155 }
2156 } else {
2157 browse.switch_view();
2158 }
2159 }
2160 KeyCode::Esc => self.screen = AppScreen::MainMenu(MainMenuScreen::new()),
2161 _ => {}
2162 }
2163 Ok(false)
2164 }
2165
2166 async fn handle_execute(&mut self, key: &KeyEvent) -> Result<bool> {
2169 let execute = match &mut self.screen {
2170 AppScreen::Execute(e) => e,
2171 _ => return Ok(false),
2172 };
2173 match key.code {
2174 KeyCode::Tab => execute.next_field(),
2175 KeyCode::BackTab => execute.previous_field(),
2176 KeyCode::Char(c) => execute.add_char_to_focused(c),
2177 KeyCode::Backspace => execute.delete_char_from_focused(),
2178 KeyCode::Enter => {
2179 let endpoint = execute.endpoint.clone();
2180 let query = execute.get_query_params();
2181 let body = if endpoint.has_body && !execute.body_text.is_empty() {
2182 Some(serde_json::from_str(&execute.body_text)?)
2183 } else {
2184 None
2185 };
2186 let resolved_path =
2187 match resolve_path_template(&endpoint.path, &execute.get_path_params()) {
2188 Ok(p) => p,
2189 Err(e) => {
2190 self.screen = AppScreen::Result(ResultScreen::new(
2191 serde_json::json!({ "error": format!("{e}") }),
2192 None,
2193 None,
2194 ));
2195 return Ok(false);
2196 }
2197 };
2198 match self
2199 .client
2200 .request_json(&endpoint.method, &resolved_path, &query, body)
2201 .await
2202 {
2203 Ok(result) => {
2204 self.screen = AppScreen::Result(ResultScreen::new(
2205 result,
2206 Some(&endpoint.method),
2207 Some(resolved_path.as_str()),
2208 ));
2209 }
2210 Err(e) => {
2211 self.screen = AppScreen::Result(ResultScreen::new(
2212 serde_json::json!({ "error": format!("{e}") }),
2213 None,
2214 None,
2215 ));
2216 }
2217 }
2218 }
2219 KeyCode::Esc => {
2220 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
2221 }
2222 _ => {}
2223 }
2224 Ok(false)
2225 }
2226
2227 fn handle_result(&mut self, key: &KeyEvent) -> Result<bool> {
2230 use super::screens::result::ResultViewMode;
2231
2232 let result = match &mut self.screen {
2233 AppScreen::Result(r) => r,
2234 _ => return Ok(false),
2235 };
2236 match key.code {
2237 KeyCode::Up | KeyCode::Char('k') => {
2238 if result.view_mode == ResultViewMode::Json {
2239 result.scroll_up(1);
2240 } else {
2241 result.table_previous();
2242 }
2243 }
2244 KeyCode::Down => {
2245 if result.view_mode == ResultViewMode::Json {
2246 result.scroll_down(1);
2247 } else {
2248 result.table_next();
2249 }
2250 }
2251 KeyCode::Char('j') if result.view_mode == ResultViewMode::Json => {
2252 result.scroll_down(1);
2253 }
2254 KeyCode::PageUp => {
2255 if result.view_mode == ResultViewMode::Table {
2256 result.table_page_up();
2257 } else {
2258 result.scroll_up(10);
2259 }
2260 }
2261 KeyCode::PageDown => {
2262 if result.view_mode == ResultViewMode::Table {
2263 result.table_page_down();
2264 } else {
2265 result.scroll_down(10);
2266 }
2267 }
2268 KeyCode::Char('t') if result.table_row_count > 0 => {
2269 result.switch_view_mode();
2270 }
2271 KeyCode::Enter
2272 if result.view_mode == ResultViewMode::Table && result.table_row_count > 0 =>
2273 {
2274 if let Some(item) = result.get_selected_item_value() {
2275 let prev = std::mem::replace(
2276 &mut self.screen,
2277 AppScreen::MainMenu(MainMenuScreen::new()),
2278 );
2279 if let AppScreen::Result(rs) = prev {
2280 self.screen = AppScreen::ResultDetail(ResultDetailScreen::new(rs, item));
2281 }
2282 }
2283 }
2284 KeyCode::Esc => {
2285 result.clear_message();
2286 self.screen = AppScreen::Browse(BrowseScreen::new(self.registry.clone()));
2287 }
2288 KeyCode::Char('q') => return Ok(true),
2289 _ => {}
2290 }
2291 Ok(false)
2292 }
2293
2294 fn handle_result_detail(&mut self, key: &KeyEvent) -> Result<bool> {
2297 let detail = match &mut self.screen {
2298 AppScreen::ResultDetail(d) => d,
2299 _ => return Ok(false),
2300 };
2301 match key.code {
2302 KeyCode::Up | KeyCode::Char('k') => detail.scroll_up(1),
2303 KeyCode::Down | KeyCode::Char('j') => detail.scroll_down(1),
2304 KeyCode::PageUp => detail.scroll_up(10),
2305 KeyCode::PageDown => detail.scroll_down(10),
2306 KeyCode::Char('o') => detail.open_image_url(),
2307 KeyCode::Esc => {
2308 detail.clear_message();
2309 let prev =
2310 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2311 if let AppScreen::ResultDetail(d) = prev {
2312 self.screen = AppScreen::Result(d.parent);
2313 }
2314 }
2315 KeyCode::Char('q') => return Ok(true),
2316 _ => {}
2317 }
2318 Ok(false)
2319 }
2320
2321 fn handle_game_detail(&mut self, key: &KeyEvent) -> Result<bool> {
2324 use super::path_picker::PathPickerEvent;
2325 let detail = match &mut self.screen {
2326 AppScreen::GameDetail(d) => d,
2327 _ => return Ok(false),
2328 };
2329
2330 if let Some(picker) = detail.save_upload_picker.as_mut() {
2331 if key.code == KeyCode::Esc {
2332 detail.save_upload_picker = None;
2333 detail.clear_message();
2334 return Ok(false);
2335 }
2336 match picker.handle_key(key) {
2337 PathPickerEvent::Confirmed(path) => {
2338 let rom_id = detail.rom.id;
2339 detail.save_upload_picker = None;
2340 detail.message = Some("Uploading save...".into());
2341 detail.message_clear_at = None;
2342 let client = self.client.clone();
2343 let tx = self.save_upload_tx.clone();
2344 tokio::spawn(async move {
2345 let result = client
2346 .upload_save_file(rom_id, None, &path)
2347 .await
2348 .map(|_| ())
2349 .map_err(|e| format!("{e:#}"));
2350 let _ = tx.send(SaveUploadDone { rom_id, result });
2351 });
2352 }
2353 PathPickerEvent::None => {}
2354 }
2355 return Ok(false);
2356 }
2357
2358 if !detail.download_completion_acknowledged {
2361 if let Ok(list) = detail.downloads.lock() {
2362 let has_completed = list.iter().any(|j| {
2363 j.rom_id == detail.rom.id
2364 && matches!(
2365 j.status,
2366 crate::core::download::DownloadStatus::Done
2367 | crate::core::download::DownloadStatus::SkippedAlreadyExists
2368 | crate::core::download::DownloadStatus::Cancelled
2369 | crate::core::download::DownloadStatus::FinalizeFailed(_)
2370 | crate::core::download::DownloadStatus::Error(_)
2371 )
2372 });
2373 let is_still_downloading = list.iter().any(|j| {
2374 j.rom_id == detail.rom.id
2375 && matches!(j.status, crate::core::download::DownloadStatus::Downloading)
2376 });
2377 if has_completed && !is_still_downloading {
2379 detail.download_completion_acknowledged = true;
2380 }
2381 }
2382 }
2383
2384 let wants_extras = matches!(key.code, KeyCode::Char('e') | KeyCode::Char('E'))
2385 || (key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::SHIFT));
2386 if wants_extras {
2387 if !detail.has_any_extras() {
2388 detail.message = Some("No extras available for this ROM".to_string());
2389 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
2390 return Ok(false);
2391 }
2392 let prev =
2393 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2394 if let AppScreen::GameDetail(g) = prev {
2395 self.screen = AppScreen::ExtrasPicker(Box::new(ExtrasPickerScreen::new(
2396 g,
2397 &self.config.extras_defaults,
2398 )));
2399 }
2400 return Ok(false);
2401 }
2402
2403 match key.code {
2404 KeyCode::Up | KeyCode::Char('k') => detail.save_selection_previous(),
2405 KeyCode::Down | KeyCode::Char('j') => detail.save_selection_next(),
2406 KeyCode::Char('u') => detail.open_save_upload_picker(),
2407 KeyCode::Char('D') => {
2408 let Some(save) = detail.selected_save().cloned() else {
2409 detail.message = Some("No save selected".into());
2410 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(3));
2411 return Ok(false);
2412 };
2413 let rom_id = detail.rom.id;
2414 let game_name = detail.rom.name.clone();
2415 let save_dir = resolved_save_dir(&self.config);
2416 detail.message = Some("Downloading save...".into());
2417 detail.message_clear_at = None;
2418 let client = self.client.clone();
2419 let tx = self.save_download_tx.clone();
2420 tokio::spawn(async move {
2421 let result = async {
2422 let bytes = client.download_save_content(save.id, None, None).await?;
2423 let target_dir = save_dir.join(safe_path_segment(&game_name));
2424 tokio::fs::create_dir_all(&target_dir).await?;
2425 let filename = if save.file_name.trim().is_empty() {
2426 format!("save-{}.sav", save.id)
2427 } else {
2428 save.file_name.clone()
2429 };
2430 let target = unique_save_path(&target_dir, &filename);
2431 tokio::fs::write(&target, bytes).await?;
2432 Ok::<PathBuf, anyhow::Error>(target)
2433 }
2434 .await
2435 .map_err(|e| format!("{e:#}"));
2436 let _ = tx.send(SaveDownloadDone { rom_id, result });
2437 });
2438 }
2439 KeyCode::Enter if !detail.has_started_download => {
2442 match self.downloads.start_download(
2443 &detail.rom,
2444 self.client.clone(),
2445 Some(self.config.download_dir.as_str()),
2446 ) {
2447 Ok(()) => {
2448 detail.has_started_download = true;
2449 if has_update_or_dlc_extras(&detail.rom, &detail.other_files) {
2450 detail.message = Some(
2451 "Updates/DLC available. Press e to download extras.".to_string(),
2452 );
2453 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
2454 }
2455 }
2456 Err(err) => {
2457 detail.has_started_download = false;
2458 detail.message = Some(format!(
2459 "Download blocked: {err}. Fix ROMs directory in settings/setup."
2460 ));
2461 }
2462 }
2463 }
2464 KeyCode::Char('o') => detail.open_cover(),
2465 KeyCode::Char('m') => detail.toggle_technical(),
2466 KeyCode::Esc => {
2467 detail.clear_message();
2468 let prev =
2469 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2470 if let AppScreen::GameDetail(g) = prev {
2471 self.screen = match g.previous {
2472 GameDetailPrevious::Library(l) => AppScreen::LibraryBrowse(*l),
2473 GameDetailPrevious::Search(s) => AppScreen::Search(s),
2474 };
2475 }
2476 }
2477 KeyCode::Char('q') => return Ok(true),
2478 _ => {}
2479 }
2480 Ok(false)
2481 }
2482
2483 fn handle_extras_picker(&mut self, key: &KeyEvent) -> Result<bool> {
2484 let picker = match &mut self.screen {
2485 AppScreen::ExtrasPicker(p) => p,
2486 _ => return Ok(false),
2487 };
2488 picker.tick_message();
2489
2490 match key.code {
2491 KeyCode::Esc => {
2492 let prev =
2493 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2494 if let AppScreen::ExtrasPicker(p) = prev {
2495 self.screen = AppScreen::GameDetail(p.previous);
2496 }
2497 }
2498 KeyCode::Up | KeyCode::Char('k') => picker.move_up(),
2499 KeyCode::Down | KeyCode::Char('j') => picker.move_down(),
2500 KeyCode::Char(' ') => picker.toggle_current(),
2501 KeyCode::Char('a') | KeyCode::Char('A') => picker.toggle_all(),
2502 KeyCode::Enter => {
2503 if picker.selected_count() == 0 {
2504 picker.show_message(
2505 "Select at least one item (Space to toggle)",
2506 Duration::from_secs(2),
2507 );
2508 return Ok(false);
2509 }
2510 let targets =
2511 match picker.build_selected_targets(Some(self.config.download_dir.as_str())) {
2512 Ok(t) => t,
2513 Err(e) => {
2514 picker.show_message(format!("{e:#}"), Duration::from_secs(4));
2515 return Ok(false);
2516 }
2517 };
2518 let rom = picker.rom.clone();
2519 let prev =
2520 std::mem::replace(&mut self.screen, AppScreen::MainMenu(MainMenuScreen::new()));
2521 if let AppScreen::ExtrasPicker(p) = prev {
2522 match self.downloads.start_extras_download(
2523 &rom,
2524 targets,
2525 self.client.clone(),
2526 Some(self.config.download_dir.as_str()),
2527 ) {
2528 Ok(()) => {
2529 self.screen = AppScreen::GameDetail(p.previous);
2530 }
2531 Err(e) => {
2532 let mut detail = *p.previous;
2533 detail.message = Some(format!("Extras: {e:#}"));
2534 detail.message_clear_at = Some(Instant::now() + Duration::from_secs(5));
2535 self.screen = AppScreen::GameDetail(Box::new(detail));
2536 }
2537 }
2538 }
2539 }
2540 KeyCode::Char('q') => return Ok(true),
2541 _ => {}
2542 }
2543 Ok(false)
2544 }
2545
2546 async fn handle_setup_wizard(&mut self, key: &KeyEvent) -> Result<bool> {
2549 let wizard = match &mut self.screen {
2550 AppScreen::SetupWizard(w) => w,
2551 _ => return Ok(false),
2552 };
2553
2554 if wizard.handle_key(key)? {
2555 self.screen = AppScreen::Settings(SettingsScreen::new(
2557 &self.config,
2558 self.server_version.as_deref(),
2559 self.save_sync_compat.clone(),
2560 ));
2561 return Ok(false);
2562 }
2563
2564 if wizard.testing {
2565 let result = wizard.try_connect_and_persist(self.client.verbose()).await;
2566 wizard.testing = false;
2567 match result {
2568 Ok(cfg) => {
2569 let auth_ok = cfg.auth.is_some();
2570 self.config = cfg;
2571 if let Ok(new_client) = RommClient::new(&self.config, self.client.verbose()) {
2572 self.client = new_client;
2573 }
2574 let mut settings = SettingsScreen::new(
2575 &self.config,
2576 self.server_version.as_deref(),
2577 self.save_sync_compat.clone(),
2578 );
2579 if auth_ok {
2580 settings.message = Some((
2581 "Authentication updated successfully".to_string(),
2582 Color::Green,
2583 ));
2584 } else {
2585 settings.message = Some((
2586 "Saved configuration but credentials could not be loaded from the OS keyring (see logs)."
2587 .to_string(),
2588 Color::Yellow,
2589 ));
2590 }
2591 self.screen = AppScreen::Settings(settings);
2592 }
2593 Err(e) => {
2594 wizard.error = Some(format!("{e:#}"));
2595 }
2596 }
2597 }
2598 Ok(false)
2599 }
2600
2601 fn render(&mut self, f: &mut ratatui::Frame) {
2606 let area = f.area();
2607 if let Some(ref splash) = self.startup_splash {
2608 connected_splash::render(f, area, splash);
2609 return;
2610 }
2611 match &mut self.screen {
2612 AppScreen::MainMenu(menu) => menu.render(f, area),
2613 AppScreen::LibraryBrowse(lib) => {
2614 lib.render(f, area);
2615 if let Some((x, y)) = lib.upload_prompt_cursor(area) {
2616 f.set_cursor_position((x, y));
2617 }
2618 }
2619 AppScreen::Search(search) => {
2620 search.render(f, area);
2621 if let Some((x, y)) = search.cursor_position(area) {
2622 f.set_cursor_position((x, y));
2623 }
2624 }
2625 AppScreen::Settings(settings) => {
2626 settings.render(f, area);
2627 if let Some((x, y)) = settings.cursor_position(area) {
2628 f.set_cursor_position((x, y));
2629 }
2630 }
2631 AppScreen::Browse(browse) => browse.render(f, area),
2632 AppScreen::Execute(execute) => {
2633 execute.render(f, area);
2634 if let Some((x, y)) = execute.cursor_position(area) {
2635 f.set_cursor_position((x, y));
2636 }
2637 }
2638 AppScreen::Result(result) => result.render(f, area),
2639 AppScreen::ResultDetail(detail) => detail.render(f, area),
2640 AppScreen::GameDetail(detail) => detail.render(f, area),
2641 AppScreen::ExtrasPicker(picker) => picker.render(f, area),
2642 AppScreen::Download(d) => d.render(f, area),
2643 AppScreen::SetupWizard(wizard) => {
2644 wizard.render(f, area);
2645 if let Some((x, y)) = wizard.cursor_pos(area) {
2646 f.set_cursor_position((x, y));
2647 }
2648 }
2649 }
2650
2651 if self.show_keyboard_help {
2652 keyboard_help::render_keyboard_help(f, area);
2653 }
2654
2655 if let Some(prompt) = &self.startup_update_prompt {
2656 let popup_w = 44;
2657 let popup_h = 10;
2658 let popup_area = ratatui::layout::Rect {
2659 x: area.width.saturating_sub(popup_w) / 2,
2660 y: area.height.saturating_sub(popup_h) / 2,
2661 width: popup_w.min(area.width),
2662 height: popup_h.min(area.height),
2663 };
2664 f.render_widget(ratatui::widgets::Clear, popup_area);
2665
2666 let block = ratatui::widgets::Block::default()
2667 .title(" Update Available ")
2668 .title_alignment(ratatui::layout::Alignment::Center)
2669 .borders(ratatui::widgets::Borders::ALL)
2670 .border_style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
2671
2672 if prompt.updating {
2673 let text = vec![
2674 ratatui::text::Line::from(""),
2675 ratatui::text::Line::from("Downloading and installing...")
2676 .alignment(ratatui::layout::Alignment::Center),
2677 ratatui::text::Line::from("Please wait.")
2678 .alignment(ratatui::layout::Alignment::Center),
2679 ratatui::text::Line::from(""),
2680 ratatui::text::Line::from("This may take a few moments.")
2681 .alignment(ratatui::layout::Alignment::Center)
2682 .style(
2683 ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray),
2684 ),
2685 ];
2686 let paragraph = ratatui::widgets::Paragraph::new(text).block(block);
2687 f.render_widget(paragraph, popup_area);
2688 } else {
2689 let text = vec![
2690 ratatui::text::Line::from(vec![
2691 ratatui::text::Span::raw("Current: "),
2692 ratatui::text::Span::styled(
2693 &prompt.status.current_version,
2694 ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray),
2695 ),
2696 ])
2697 .alignment(ratatui::layout::Alignment::Center),
2698 ratatui::text::Line::from(vec![
2699 ratatui::text::Span::raw("Latest: "),
2700 ratatui::text::Span::styled(
2701 &prompt.status.latest_version,
2702 ratatui::style::Style::default()
2703 .fg(ratatui::style::Color::Green)
2704 .add_modifier(ratatui::style::Modifier::BOLD),
2705 ),
2706 ])
2707 .alignment(ratatui::layout::Alignment::Center),
2708 ratatui::text::Line::from(""),
2709 ratatui::text::Line::from("Would you like to update?")
2710 .alignment(ratatui::layout::Alignment::Center),
2711 ratatui::text::Line::from(""),
2712 ratatui::text::Line::from(vec![
2713 ratatui::text::Span::styled(
2714 "Y",
2715 ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
2716 ),
2717 ratatui::text::Span::raw(": Yes (update) "),
2718 ratatui::text::Span::styled(
2719 "N",
2720 ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
2721 ),
2722 ratatui::text::Span::raw(": No (skip)"),
2723 ])
2724 .alignment(ratatui::layout::Alignment::Center),
2725 ratatui::text::Line::from(vec![
2726 ratatui::text::Span::styled(
2727 "C",
2728 ratatui::style::Style::default().fg(ratatui::style::Color::Yellow),
2729 ),
2730 ratatui::text::Span::raw(": View changelog"),
2731 ])
2732 .alignment(ratatui::layout::Alignment::Center),
2733 ];
2734 let paragraph = ratatui::widgets::Paragraph::new(text).block(block);
2735 f.render_widget(paragraph, popup_area);
2736 }
2737 }
2738
2739 if let Some(ref err) = self.global_error {
2740 let popup_area = ratatui::layout::Rect {
2741 x: area.width.saturating_sub(60) / 2,
2742 y: area.height.saturating_sub(10) / 2,
2743 width: 60.min(area.width),
2744 height: 10.min(area.height),
2745 };
2746 f.render_widget(ratatui::widgets::Clear, popup_area);
2747 let block = ratatui::widgets::Block::default()
2748 .title("Error")
2749 .borders(ratatui::widgets::Borders::ALL)
2750 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
2751 let text = format!("{}\n\nPress Esc to dismiss", err);
2752 let paragraph = ratatui::widgets::Paragraph::new(text)
2753 .block(block)
2754 .wrap(ratatui::widgets::Wrap { trim: true });
2755 f.render_widget(paragraph, popup_area);
2756 }
2757
2758 if let Some(ref notice) = self.global_notice {
2759 let popup_area = ratatui::layout::Rect {
2760 x: area.width.saturating_sub(60) / 2,
2761 y: area.height.saturating_sub(10) / 2,
2762 width: 60.min(area.width),
2763 height: 10.min(area.height),
2764 };
2765 f.render_widget(ratatui::widgets::Clear, popup_area);
2766 let block = ratatui::widgets::Block::default()
2767 .title("Notice")
2768 .borders(ratatui::widgets::Borders::ALL)
2769 .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
2770 let text = format!("{notice}\n\nPress Esc to dismiss");
2771 let paragraph = ratatui::widgets::Paragraph::new(text)
2772 .block(block)
2773 .wrap(ratatui::widgets::Wrap { trim: true });
2774 f.render_widget(paragraph, popup_area);
2775 }
2776 }
2777}
2778
2779#[cfg(test)]
2780mod tests {
2781 use super::*;
2782 use crate::config::{Config, ExtrasDefaults};
2783 use crate::openapi::EndpointRegistry;
2784 use crate::tui::screens::library_browse::LibraryBrowseScreen;
2785 use crate::tui::screens::{GameDetailPrevious, GameDetailScreen, SearchScreen};
2786 use crate::types::Platform;
2787 use crate::update::UpdateStatus;
2788 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2789 use serde_json::json;
2790
2791 fn platform(id: u64, name: &str, rom_count: u64) -> Platform {
2792 serde_json::from_value(json!({
2793 "id": id,
2794 "slug": format!("p{id}"),
2795 "fs_slug": format!("p{id}"),
2796 "rom_count": rom_count,
2797 "name": name,
2798 "igdb_slug": null,
2799 "moby_slug": null,
2800 "hltb_slug": null,
2801 "custom_name": null,
2802 "igdb_id": null,
2803 "sgdb_id": null,
2804 "moby_id": null,
2805 "launchbox_id": null,
2806 "ss_id": null,
2807 "ra_id": null,
2808 "hasheous_id": null,
2809 "tgdb_id": null,
2810 "flashpoint_id": null,
2811 "category": null,
2812 "generation": null,
2813 "family_name": null,
2814 "family_slug": null,
2815 "url": null,
2816 "url_logo": null,
2817 "firmware": [],
2818 "aspect_ratio": null,
2819 "created_at": "",
2820 "updated_at": "",
2821 "fs_size_bytes": 0,
2822 "is_unidentified": false,
2823 "is_identified": true,
2824 "missing_from_fs": false,
2825 "display_name": null
2826 }))
2827 .expect("valid platform fixture")
2828 }
2829
2830 fn app_with_library(platforms: Vec<Platform>) -> App {
2831 let config = Config {
2832 base_url: "http://127.0.0.1:9".into(),
2833 download_dir: "/tmp".into(),
2834 use_https: false,
2835 auth: None,
2836 extras_defaults: ExtrasDefaults::default(),
2837 save_sync: Default::default(),
2838 };
2839 let client = RommClient::new(&config, false).expect("client");
2840 let mut app = App::new(
2841 client,
2842 config,
2843 EndpointRegistry::default(),
2844 None,
2845 None,
2846 None,
2847 );
2848 app.screen = AppScreen::LibraryBrowse(LibraryBrowseScreen::new(platforms, vec![]));
2849 app
2850 }
2851
2852 fn update_status_fixture() -> UpdateStatus {
2853 UpdateStatus {
2854 current_version: "0.25.0".into(),
2855 latest_version: "0.26.0".into(),
2856 release_tag: "v0.26.0".into(),
2857 should_update: true,
2858 release_url: "https://github.com/patricksmill/romm-cli/releases/tag/v0.26.0".into(),
2859 changelog_url: "https://github.com/patricksmill/romm-cli/blob/main/CHANGELOG.md".into(),
2860 }
2861 }
2862
2863 fn rom_fixture() -> crate::types::Rom {
2864 serde_json::from_value(json!({
2865 "id": 10,
2866 "platform_id": 1,
2867 "platform_slug": null,
2868 "platform_fs_slug": null,
2869 "platform_custom_name": null,
2870 "platform_display_name": null,
2871 "fs_name": "sample.zip",
2872 "fs_name_no_tags": "sample",
2873 "fs_name_no_ext": "sample",
2874 "fs_extension": "zip",
2875 "fs_path": "/sample.zip",
2876 "fs_size_bytes": 100,
2877 "name": "Sample",
2878 "slug": null,
2879 "summary": null,
2880 "path_cover_small": null,
2881 "path_cover_large": null,
2882 "url_cover": null,
2883 "has_manual": false,
2884 "path_manual": null,
2885 "url_manual": null,
2886 "is_unidentified": false,
2887 "is_identified": true
2888 }))
2889 .expect("valid rom fixture")
2890 }
2891
2892 fn empty_rom_list_with_total(total: u64) -> RomList {
2893 RomList {
2894 items: vec![],
2895 total,
2896 limit: 50,
2897 offset: 0,
2898 }
2899 }
2900
2901 #[tokio::test]
2902 async fn list_move_to_zero_rom_selection_does_not_queue_deferred_load() {
2903 let mut app = app_with_library(vec![platform(1, "HasRoms", 5), platform(2, "Empty", 0)]);
2904
2905 assert!(!app
2906 .handle_key_event(&KeyEvent::new(KeyCode::Down, KeyModifiers::empty()))
2907 .await
2908 .expect("key handled"));
2909 assert!(
2910 app.deferred_load_roms.is_none(),
2911 "selection move to zero-rom platform should not queue deferred ROM load"
2912 );
2913 }
2914
2915 #[test]
2916 fn ctrl_c_is_treated_as_force_quit() {
2917 let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2918 assert!(App::is_force_quit_key(&ctrl_c));
2919
2920 let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty());
2921 assert!(!App::is_force_quit_key(&plain_c));
2922 }
2923
2924 #[test]
2925 fn primary_rom_load_stale_gen_is_ignored() {
2926 assert!(!super::primary_rom_load_result_is_current(1, 2));
2927 assert!(super::primary_rom_load_result_is_current(3, 3));
2928 }
2929
2930 #[tokio::test]
2931 async fn game_detail_esc_returns_to_previous_library_screen() {
2932 let mut app = app_with_library(vec![platform(1, "NES", 1)]);
2933 let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
2934 let detail = GameDetailScreen::new(
2935 rom_fixture(),
2936 Vec::new(),
2937 GameDetailPrevious::Library(Box::new(previous)),
2938 app.downloads.shared(),
2939 );
2940 app.screen = AppScreen::GameDetail(Box::new(detail));
2941
2942 let quit = app
2943 .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
2944 .await
2945 .expect("esc handled");
2946 assert!(!quit);
2947 assert!(matches!(app.screen, AppScreen::LibraryBrowse(_)));
2948 }
2949
2950 #[tokio::test]
2951 async fn startup_update_prompt_skip_closes_prompt() {
2952 let config = Config {
2953 base_url: "http://127.0.0.1:9".into(),
2954 download_dir: "/tmp".into(),
2955 use_https: false,
2956 auth: None,
2957 extras_defaults: ExtrasDefaults::default(),
2958 save_sync: Default::default(),
2959 };
2960 let client = RommClient::new(&config, false).expect("client");
2961 let mut app = App::new(
2962 client,
2963 config,
2964 EndpointRegistry::default(),
2965 None,
2966 None,
2967 Some(update_status_fixture()),
2968 );
2969 assert!(app.startup_update_prompt.is_some());
2970 let quit = app
2971 .handle_key_event(&KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
2972 .await
2973 .expect("esc handled");
2974 assert!(!quit);
2975 assert!(app.startup_update_prompt.is_none());
2976 }
2977
2978 #[test]
2979 fn search_batch_updates_results_without_stopping_loading() {
2980 let config = Config {
2981 base_url: "http://127.0.0.1:9".into(),
2982 download_dir: "/tmp".into(),
2983 use_https: false,
2984 auth: None,
2985 extras_defaults: ExtrasDefaults::default(),
2986 save_sync: Default::default(),
2987 };
2988 let client = RommClient::new(&config, false).expect("client");
2989 let mut app = App::new(
2990 client,
2991 config,
2992 EndpointRegistry::default(),
2993 None,
2994 None,
2995 None,
2996 );
2997 let mut search = SearchScreen::new();
2998 search.loading = true;
2999 app.screen = AppScreen::Search(search);
3000
3001 app.search_load_tx
3002 .send(SearchLoadDone {
3003 query: "zelda".to_string(),
3004 event: SearchLoadEvent::Batch(empty_rom_list_with_total(120)),
3005 })
3006 .expect("send batch");
3007
3008 app.poll_search_load_results();
3009
3010 match &app.screen {
3011 AppScreen::Search(search) => {
3012 assert!(search.loading, "loading should continue after batch");
3013 assert!(search.results.is_some(), "batch should populate results");
3014 assert_eq!(search.last_searched_query.as_deref(), Some("zelda"));
3015 }
3016 _ => panic!("expected search screen"),
3017 }
3018 }
3019
3020 #[test]
3021 fn search_complete_event_stops_loading() {
3022 let config = Config {
3023 base_url: "http://127.0.0.1:9".into(),
3024 download_dir: "/tmp".into(),
3025 use_https: false,
3026 auth: None,
3027 extras_defaults: ExtrasDefaults::default(),
3028 save_sync: Default::default(),
3029 };
3030 let client = RommClient::new(&config, false).expect("client");
3031 let mut app = App::new(
3032 client,
3033 config,
3034 EndpointRegistry::default(),
3035 None,
3036 None,
3037 None,
3038 );
3039 let mut search = SearchScreen::new();
3040 search.loading = true;
3041 app.screen = AppScreen::Search(search);
3042
3043 app.search_load_tx
3044 .send(SearchLoadDone {
3045 query: "zelda".to_string(),
3046 event: SearchLoadEvent::Complete,
3047 })
3048 .expect("send complete");
3049
3050 app.poll_search_load_results();
3051
3052 match &app.screen {
3053 AppScreen::Search(search) => {
3054 assert!(!search.loading, "loading should stop after completion");
3055 }
3056 _ => panic!("expected search screen"),
3057 }
3058 }
3059
3060 #[tokio::test]
3061 async fn pressing_e_with_no_extras_shows_toast_not_picker() {
3062 let mut app = app_with_library(vec![platform(1, "NES", 1)]);
3063 let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
3064 let detail = GameDetailScreen::new(
3065 rom_fixture(),
3066 Vec::new(),
3067 GameDetailPrevious::Library(Box::new(previous)),
3068 app.downloads.shared(),
3069 );
3070 app.screen = AppScreen::GameDetail(Box::new(detail));
3071
3072 app.handle_key_event(&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()))
3073 .await
3074 .expect("handled");
3075
3076 match &app.screen {
3077 AppScreen::GameDetail(d) => {
3078 assert!(
3079 d.message
3080 .as_deref()
3081 .is_some_and(|m| m.contains("No extras")),
3082 "expected toast, got {:?}",
3083 d.message
3084 );
3085 }
3086 _ => panic!("expected game detail"),
3087 }
3088 }
3089
3090 #[tokio::test]
3091 async fn pressing_e_with_extras_opens_picker() {
3092 let mut rom = rom_fixture();
3093 rom.url_cover = Some("https://example.com/c.png".into());
3094 let mut app = app_with_library(vec![platform(1, "NES", 1)]);
3095 let previous = LibraryBrowseScreen::new(vec![platform(1, "NES", 1)], vec![]);
3096 let detail = GameDetailScreen::new(
3097 rom,
3098 Vec::new(),
3099 GameDetailPrevious::Library(Box::new(previous)),
3100 app.downloads.shared(),
3101 );
3102 app.screen = AppScreen::GameDetail(Box::new(detail));
3103
3104 app.handle_key_event(&KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()))
3105 .await
3106 .expect("handled");
3107
3108 assert!(
3109 matches!(app.screen, AppScreen::ExtrasPicker(_)),
3110 "expected extras picker"
3111 );
3112 }
3113}