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