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.
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.
+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) {
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());
}