1use tokio::sync::mpsc;
4
5use crate::state::{AppState, PackageItem};
6
7pub fn move_sel_cached(
28 app: &mut AppState,
29 delta: isize,
30 details_tx: &mpsc::UnboundedSender<PackageItem>,
31 comments_tx: &mpsc::UnboundedSender<String>,
32) {
33 if app.results.is_empty() {
34 return;
35 }
36 let len = isize::try_from(app.results.len()).unwrap_or(isize::MAX);
37 let mut idx = isize::try_from(app.selected).unwrap_or(0) + delta;
38 if idx < 0 {
39 idx = 0;
40 }
41 if idx >= len {
42 idx = len - 1;
43 }
44 app.selected = usize::try_from(idx).unwrap_or(0);
45 app.list_state.select(Some(app.selected));
46 if let Some(item) = app.results.get(app.selected).cloned() {
47 app.details_focus = Some(item.name.clone());
49 crate::logic::clear_stale_pkgbuild_checks_for_selection(app, item.name.as_str());
50
51 app.details.name.clone_from(&item.name);
53 app.details.version.clone_from(&item.version);
54 app.details.description.clear();
55 match &item.source {
56 crate::state::Source::Official { repo, arch } => {
57 app.details.repository.clone_from(repo);
58 app.details.architecture.clone_from(arch);
59 }
60 crate::state::Source::Aur => {
61 app.details.repository = "AUR".to_string();
62 app.details.architecture = "any".to_string();
63 }
64 }
65
66 if let Some(cached) = app.details_cache.get(&item.name).cloned() {
67 app.details = cached;
68 } else {
69 let _ = details_tx.send(item.clone());
70 }
71
72 if app.pkgb_visible {
74 let needs_reload = app.pkgb_package_name.as_deref() != Some(item.name.as_str());
75 if needs_reload {
76 app.pkgb_reload_requested_at = Some(std::time::Instant::now());
78 app.pkgb_reload_requested_for = Some(item.name.clone());
79 app.pkgb_text = None; app.pkgb_package_name = None;
85 }
86 }
87
88 if app.comments_visible && matches!(item.source, crate::state::Source::Aur) {
90 let needs_update = app
91 .comments_package_name
92 .as_deref()
93 .is_none_or(|cached_name| cached_name != item.name.as_str());
94 if needs_update {
95 if app
97 .comments_package_name
98 .as_ref()
99 .is_some_and(|cached_name| {
100 cached_name == &item.name && !app.comments.is_empty()
101 })
102 {
103 app.comments_scroll = 0;
105 } else {
106 app.comments.clear();
108 app.comments_package_name = None;
109 app.comments_fetched_at = None;
110 app.comments_scroll = 0;
111 app.comments_loading = true;
112 app.comments_error = None;
113 let _ = comments_tx.send(item.name.clone());
114 }
115 }
116 }
117 }
118
119 let abs_delta_usize: usize = if delta < 0 {
121 usize::try_from(-delta).unwrap_or(0)
122 } else {
123 usize::try_from(delta).unwrap_or(0)
124 };
125 if abs_delta_usize > 0 {
126 let add = u32::try_from(abs_delta_usize.min(u32::MAX as usize))
127 .expect("value is bounded by u32::MAX");
128 app.scroll_moves = app.scroll_moves.saturating_add(add);
129 }
130 if app.need_ring_prefetch {
131 crate::logic::set_allowed_only_selected(app);
133 app.ring_resume_at =
134 Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
135 return;
136 }
137 if app.scroll_moves > 5 {
138 app.need_ring_prefetch = true;
139 crate::logic::set_allowed_only_selected(app);
140 app.ring_resume_at =
141 Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
142 return;
143 }
144
145 crate::logic::set_allowed_ring(app, 30);
147 crate::logic::ring_prefetch_from_selected(app, details_tx);
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn item_official(name: &str, repo: &str) -> crate::state::PackageItem {
155 crate::state::PackageItem {
156 name: name.to_string(),
157 version: "1.0".to_string(),
158 description: format!("{name} desc"),
159 source: crate::state::Source::Official {
160 repo: repo.to_string(),
161 arch: "x86_64".to_string(),
162 },
163 popularity: None,
164 out_of_date: None,
165 orphaned: false,
166 }
167 }
168
169 #[tokio::test]
170 async fn move_sel_cached_clamps_and_requests_details() {
182 let mut app = crate::state::AppState {
183 results: vec![
184 crate::state::PackageItem {
185 name: "aur1".into(),
186 version: "1".into(),
187 description: String::new(),
188 source: crate::state::Source::Aur,
189 popularity: None,
190 out_of_date: None,
191 orphaned: false,
192 },
193 item_official("pkg2", "core"),
194 ],
195 selected: 0,
196 ..Default::default()
197 };
198
199 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
200 let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
201 move_sel_cached(&mut app, 1, &tx, &comments_tx);
202 assert_eq!(app.selected, 1);
203 assert_eq!(app.details.repository.to_lowercase(), "core");
204 assert_eq!(app.details.architecture.to_lowercase(), "x86_64");
205 let got = tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv())
206 .await
207 .ok()
208 .flatten();
209 assert!(got.is_some());
210 move_sel_cached(&mut app, -100, &tx, &comments_tx);
211 assert_eq!(app.selected, 0);
212 move_sel_cached(&mut app, 0, &tx, &comments_tx);
213 assert_eq!(app.details.repository, "AUR");
214 assert_eq!(app.details.architecture, "any");
215 }
216
217 #[tokio::test]
218 async fn move_sel_cached_clears_pkgb_package_name_when_switching_with_viewer_open() {
232 let mut app = crate::state::AppState {
233 results: vec![
234 crate::state::PackageItem {
235 name: "aur1".into(),
236 version: "1".into(),
237 description: String::new(),
238 source: crate::state::Source::Aur,
239 popularity: None,
240 out_of_date: None,
241 orphaned: false,
242 },
243 item_official("linux", "core"),
244 ],
245 selected: 0,
246 pkgb_visible: true,
247 pkgb_text: Some("# pkgbuild\n".into()),
248 pkgb_package_name: Some("aur1".into()),
249 ..Default::default()
250 };
251 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
252 let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
253 move_sel_cached(&mut app, 1, &tx, &comments_tx);
254 assert_eq!(app.selected, 1);
255 assert!(app.pkgb_text.is_none());
256 assert!(
257 app.pkgb_package_name.is_none(),
258 "pkgb_package_name must not outlive cleared text; stale ignores would strand loading state"
259 );
260 assert_eq!(app.pkgb_reload_requested_for.as_deref(), Some("linux"));
261 }
262
263 #[tokio::test]
264 async fn move_sel_cached_uses_details_cache() {
275 let mut app = crate::state::AppState::default();
276 let pkg = item_official("pkg", "core");
277 app.results = vec![pkg.clone()];
278 app.details_cache.insert(
279 pkg.name.clone(),
280 crate::state::PackageDetails {
281 repository: "core".into(),
282 name: pkg.name.clone(),
283 version: pkg.version.clone(),
284 architecture: "x86_64".into(),
285 ..Default::default()
286 },
287 );
288 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
289 let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
290 move_sel_cached(&mut app, 0, &tx, &comments_tx);
291 let none = tokio::time::timeout(std::time::Duration::from_millis(30), rx.recv())
292 .await
293 .ok()
294 .flatten();
295 assert!(none.is_none());
296 assert_eq!(app.details.name, "pkg");
297 }
298
299 #[test]
300 fn fast_scroll_sets_gating_and_defers_ring() {
313 let mut app = crate::state::AppState {
314 results: vec![
315 item_official("a", "core"),
316 item_official("b", "extra"),
317 item_official("c", "extra"),
318 item_official("d", "extra"),
319 item_official("e", "extra"),
320 item_official("f", "extra"),
321 item_official("g", "extra"),
322 ],
323 ..Default::default()
324 };
325 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<crate::state::PackageItem>();
326 let (comments_tx, _comments_rx) = tokio::sync::mpsc::unbounded_channel();
327 move_sel_cached(&mut app, 6, &tx, &comments_tx);
328 assert!(app.need_ring_prefetch);
329 assert!(app.ring_resume_at.is_some());
330 crate::logic::set_allowed_only_selected(&app);
331 assert!(crate::logic::is_allowed(&app.results[app.selected].name));
332 }
333}