diff --git a/README.md b/README.md index 546f806..80814d2 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,7 @@ Malformed individual entries—including invalid video IDs, missing required fie Feed views sort all loaded videos newest-first using available upload dates, with undated videos last. The budget limits what has been inspected: later loads may discover newer videos and insert them above older rows, while selection and pending description requests stay attached to the same video ID. Publication labels use `YYYY-MM-DD`; `~` marks an estimated UTC date, and `Unknown` means unavailable metadata. -Pane titles show animated spinners while content loads. Errors retain loaded rows and stop automatic retries; `r` refreshes the current view. +Pane titles show animated spinners while content loads. Refresh keeps the current rows and selection visible until a valid first page replaces them, including when the new result is empty. Errors retain loaded rows and stop automatic retries; `r` refreshes the current view from page one. Video info shows a thumbnail, title, author/publication details, and a clipped description excerpt. Press `d` again to read the full description in the same popup. Thumbnails and full descriptions are cached in memory across selections and views: 64 full descriptions and eight inactive decoded thumbnails, plus the selected image. Caches last for the session; a successful refresh clears them. Preview loading, decoding, and image resizing run asynchronously. diff --git a/src/main.rs b/src/main.rs index 1dd8599..da37280 100644 --- a/src/main.rs +++ b/src/main.rs @@ -358,9 +358,8 @@ impl View { } } + // Invalidate pagination, retaining displayed rows until the first replacement page is valid. fn reset(&mut self) { - self.videos.clear(); - self.selection = TableState::default(); self.loaded = false; self.next_start = 1; self.has_more = false; @@ -378,6 +377,10 @@ impl View { if feed { self.feed_cursor.accept(&page.feed_progress)?; } + if !self.loaded { + self.videos.clear(); + self.selection = TableState::default(); + } if !page.videos.is_empty() { let selected = if feed { self.selection.selected().and_then(|index| { @@ -1355,6 +1358,8 @@ impl App { if query != view.submitted_query { view.submitted_query = query.to_owned(); view.reset(); + view.videos.clear(); + view.selection = TableState::default(); self.job = None; } self.switch_view(index); @@ -1463,6 +1468,8 @@ impl App { view.source .channels(&self.config, &self.feed_channels, &view.group_channels); if channels.is_empty() { + view.videos.clear(); + view.selection = TableState::default(); view.loaded = true; view.has_more = false; self.status = format!( @@ -1560,10 +1567,11 @@ impl App { } }); let feed = self.views[self.view_index].source != ViewSource::Search; - if let Err(error) = - parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) - { - self.views[self.view_index].page_error = Some(error); + let replacing = !self.views[self.view_index].loaded; + match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) { + Ok(()) if replacing => self.close_preview(), + Err(error) => self.views[self.view_index].page_error = Some(error), + _ => {} } self.status = self.view_status(); } @@ -4563,7 +4571,7 @@ mod tests { app.apply_config(config::load(&app.config_path).unwrap()); let view = &app.views[app.view_index]; assert_eq!(view.source, group); - assert!(view.videos.is_empty()); + assert_eq!(view.videos[0].title, "Cached"); assert!(!view.loaded); assert_eq!(view.next_start, 1); assert_eq!(view.group_channels.len(), 1); @@ -8301,6 +8309,55 @@ tags.cancel = "z" assert!(app.views[1].prefetch_requested); } + #[test] + fn refresh_keeps_rows_until_a_valid_replacement_arrives() { + let mut app = app(&["tubular"]); + app.view_index = 1; + // Duplicate URLs fail request creation before any extractor is spawned. + app.feed_channels = vec!["fixture".into(), "fixture".into()]; + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old"},{"id":"rQ_J9WH6CGk","title":"Selected"}]} +{"entries":[]}"#, + false, + ); + app.views[1].selection.select(Some(1)); + app.load_view(); + assert_eq!(app.views[1].videos[1].title, "Selected"); + assert_eq!(app.views[1].selection.selected(), Some(1)); + assert!(app.views[1].page_error.is_some()); + assert_eq!(app.views[1].next_start, 1); + + app.finish_page(Ok("not JSON".into())); + assert_eq!(app.views[1].videos[1].title, "Selected"); + assert_eq!(app.views[1].selection.selected(), Some(1)); + + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Updated"}]} +{"entries":[]}"#, + true, + ); + assert_eq!(app.views[1].videos.len(), 1); + assert_eq!(app.views[1].videos[0].title, "Updated"); + assert_eq!(app.views[1].selection.selected(), Some(0)); + assert_eq!(app.views[1].next_start, PAGE_SIZE + 1); + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Next page"}]} +{"entries":[]}"#, + false, + ); + assert_eq!(app.views[1].videos[0].title, "Updated"); + assert_eq!(app.views[1].videos[1].title, "Next page"); + + app.load_view(); + assert_eq!(app.views[1].videos[0].title, "Updated"); + finish_feed_fixture(&mut app, "{\"entries\":[]}\n{\"entries\":[]}", false); + assert!(app.views[1].videos.is_empty()); + assert_eq!(app.views[1].selection.selected(), None); + } + #[test] fn loaded_feed_rows_are_visible_sorted_and_keep_selection_and_description_identity() { let mut app = app(&["tubular"]); @@ -8407,7 +8464,6 @@ tags.cancel = "z" assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), next); assert!(!app.can_prefetch()); app.views[1].reset(); - assert!(app.views[1].videos.is_empty()); assert!(app.views[1].feed_requests.is_empty()); assert!(!app.views[1].prefetch_requested); assert_eq!(app.views[1].feed_cursor.plan(1).unwrap()[0].start, 1); @@ -8510,7 +8566,7 @@ tags.cancel = "z" ..Default::default() }); assert!(!app.views[2].loaded); - assert!(app.views[2].videos.is_empty()); + assert_eq!(app.views[2].videos[0].id, "jNQXAC9IVRw"); assert!(app.views[2].feed_requests.is_empty()); assert!(!app.views[2].prefetch_requested); assert_eq!(app.views[2].next_start, 1); @@ -8568,11 +8624,9 @@ tags.cancel = "z" view.feed_requests = view.feed_cursor.plan(1).unwrap(); view.prefetch_requested = true; view.reset(); - assert!(view.videos.is_empty()); assert!(view.feed_requests.is_empty()); assert!(!view.feed_cursor.has_more()); assert!(!view.prefetch_requested); - assert_eq!(view.selection.selected(), None); assert_eq!(view.next_start, 1); assert!(!view.loaded); assert!(!view.has_more); @@ -8934,10 +8988,12 @@ tags.cancel = "z" pinned_categories: Vec::new(), }); assert_eq!(app.views[app.view_index].name, "Rust"); - assert!(app.views[app.view_index].videos.is_empty()); - assert!(app.views[app.view_index].selection.selected().is_none()); assert!(!app.views[app.view_index].loaded); assert!(app.job.is_none()); + app.load_page(); + assert!(app.views[app.view_index].videos.is_empty()); + assert!(app.views[app.view_index].selection.selected().is_none()); + assert!(app.views[app.view_index].loaded); } #[test] @@ -9011,8 +9067,6 @@ tags.cancel = "z" Some("Retained description") ); assert!(!app.views[4].loaded); - assert!(app.views[4].videos.is_empty()); - assert_eq!(app.views[4].selection.selected(), None); assert_eq!(app.views[4].next_start, 1); assert!(!app.views[4].has_more); assert!(app.views[4].feed_requests.is_empty()); @@ -9050,8 +9104,6 @@ tags.cancel = "z" }); assert_eq!(app.feed_channels, vec![direct, added, registered]); assert!(!app.views[1].loaded); - assert!(app.views[1].videos.is_empty()); - assert_eq!(app.views[1].selection.selected(), None); assert_eq!(app.views[1].next_start, 1); assert!(!app.views[1].has_more); assert!(app.views[1].feed_requests.is_empty()); @@ -9073,8 +9125,6 @@ tags.cancel = "z" }); assert_eq!(app.feed_channels, vec![direct, registered]); assert!(!app.views[1].loaded); - assert!(app.views[1].videos.is_empty()); - assert_eq!(app.views[1].selection.selected(), None); assert_eq!(app.view_index, 1); assert!(app.job.is_none()); }