fix: retain view rows until refresh results arrive

This commit is contained in:
2026-10-17 00:37:28 +02:00
parent df35b1cddb
commit 1e89be58b6
2 changed files with 70 additions and 20 deletions
+1 -1
View File
@@ -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. 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. 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.
+69 -19
View File
@@ -358,9 +358,8 @@ impl View {
} }
} }
// Invalidate pagination, retaining displayed rows until the first replacement page is valid.
fn reset(&mut self) { fn reset(&mut self) {
self.videos.clear();
self.selection = TableState::default();
self.loaded = false; self.loaded = false;
self.next_start = 1; self.next_start = 1;
self.has_more = false; self.has_more = false;
@@ -378,6 +377,10 @@ impl View {
if feed { if feed {
self.feed_cursor.accept(&page.feed_progress)?; self.feed_cursor.accept(&page.feed_progress)?;
} }
if !self.loaded {
self.videos.clear();
self.selection = TableState::default();
}
if !page.videos.is_empty() { if !page.videos.is_empty() {
let selected = if feed { let selected = if feed {
self.selection.selected().and_then(|index| { self.selection.selected().and_then(|index| {
@@ -1355,6 +1358,8 @@ impl App {
if query != view.submitted_query { if query != view.submitted_query {
view.submitted_query = query.to_owned(); view.submitted_query = query.to_owned();
view.reset(); view.reset();
view.videos.clear();
view.selection = TableState::default();
self.job = None; self.job = None;
} }
self.switch_view(index); self.switch_view(index);
@@ -1463,6 +1468,8 @@ impl App {
view.source view.source
.channels(&self.config, &self.feed_channels, &view.group_channels); .channels(&self.config, &self.feed_channels, &view.group_channels);
if channels.is_empty() { if channels.is_empty() {
view.videos.clear();
view.selection = TableState::default();
view.loaded = true; view.loaded = true;
view.has_more = false; view.has_more = false;
self.status = format!( self.status = format!(
@@ -1560,10 +1567,11 @@ impl App {
} }
}); });
let feed = self.views[self.view_index].source != ViewSource::Search; let feed = self.views[self.view_index].source != ViewSource::Search;
if let Err(error) = let replacing = !self.views[self.view_index].loaded;
parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) {
{ Ok(()) if replacing => self.close_preview(),
self.views[self.view_index].page_error = Some(error); Err(error) => self.views[self.view_index].page_error = Some(error),
_ => {}
} }
self.status = self.view_status(); self.status = self.view_status();
} }
@@ -4563,7 +4571,7 @@ mod tests {
app.apply_config(config::load(&app.config_path).unwrap()); app.apply_config(config::load(&app.config_path).unwrap());
let view = &app.views[app.view_index]; let view = &app.views[app.view_index];
assert_eq!(view.source, group); assert_eq!(view.source, group);
assert!(view.videos.is_empty()); assert_eq!(view.videos[0].title, "Cached");
assert!(!view.loaded); assert!(!view.loaded);
assert_eq!(view.next_start, 1); assert_eq!(view.next_start, 1);
assert_eq!(view.group_channels.len(), 1); assert_eq!(view.group_channels.len(), 1);
@@ -8301,6 +8309,55 @@ tags.cancel = "z"
assert!(app.views[1].prefetch_requested); 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] #[test]
fn loaded_feed_rows_are_visible_sorted_and_keep_selection_and_description_identity() { fn loaded_feed_rows_are_visible_sorted_and_keep_selection_and_description_identity() {
let mut app = app(&["tubular"]); let mut app = app(&["tubular"]);
@@ -8407,7 +8464,6 @@ tags.cancel = "z"
assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), next); assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), next);
assert!(!app.can_prefetch()); assert!(!app.can_prefetch());
app.views[1].reset(); app.views[1].reset();
assert!(app.views[1].videos.is_empty());
assert!(app.views[1].feed_requests.is_empty()); assert!(app.views[1].feed_requests.is_empty());
assert!(!app.views[1].prefetch_requested); assert!(!app.views[1].prefetch_requested);
assert_eq!(app.views[1].feed_cursor.plan(1).unwrap()[0].start, 1); assert_eq!(app.views[1].feed_cursor.plan(1).unwrap()[0].start, 1);
@@ -8510,7 +8566,7 @@ tags.cancel = "z"
..Default::default() ..Default::default()
}); });
assert!(!app.views[2].loaded); 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].feed_requests.is_empty());
assert!(!app.views[2].prefetch_requested); assert!(!app.views[2].prefetch_requested);
assert_eq!(app.views[2].next_start, 1); 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.feed_requests = view.feed_cursor.plan(1).unwrap();
view.prefetch_requested = true; view.prefetch_requested = true;
view.reset(); view.reset();
assert!(view.videos.is_empty());
assert!(view.feed_requests.is_empty()); assert!(view.feed_requests.is_empty());
assert!(!view.feed_cursor.has_more()); assert!(!view.feed_cursor.has_more());
assert!(!view.prefetch_requested); assert!(!view.prefetch_requested);
assert_eq!(view.selection.selected(), None);
assert_eq!(view.next_start, 1); assert_eq!(view.next_start, 1);
assert!(!view.loaded); assert!(!view.loaded);
assert!(!view.has_more); assert!(!view.has_more);
@@ -8934,10 +8988,12 @@ tags.cancel = "z"
pinned_categories: Vec::new(), pinned_categories: Vec::new(),
}); });
assert_eq!(app.views[app.view_index].name, "Rust"); 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.views[app.view_index].loaded);
assert!(app.job.is_none()); 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] #[test]
@@ -9011,8 +9067,6 @@ tags.cancel = "z"
Some("Retained description") Some("Retained description")
); );
assert!(!app.views[4].loaded); 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_eq!(app.views[4].next_start, 1);
assert!(!app.views[4].has_more); assert!(!app.views[4].has_more);
assert!(app.views[4].feed_requests.is_empty()); 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_eq!(app.feed_channels, vec![direct, added, registered]);
assert!(!app.views[1].loaded); 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_eq!(app.views[1].next_start, 1);
assert!(!app.views[1].has_more); assert!(!app.views[1].has_more);
assert!(app.views[1].feed_requests.is_empty()); assert!(app.views[1].feed_requests.is_empty());
@@ -9073,8 +9125,6 @@ tags.cancel = "z"
}); });
assert_eq!(app.feed_channels, vec![direct, registered]); assert_eq!(app.feed_channels, vec![direct, registered]);
assert!(!app.views[1].loaded); 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_eq!(app.view_index, 1);
assert!(app.job.is_none()); assert!(app.job.is_none());
} }