From 646a4cd878fe30bbf8b666b8857b100e16540340 Mon Sep 17 00:00:00 2001 From: ewen Date: Mon, 11 May 2026 15:08:06 +0200 Subject: [PATCH] Fix review issues: cache filtered torrents, purge stale selections, multi-select toggle, search cursor, add modal space, limit concurrent API calls --- src/app.rs | 58 +++++++++++++++++++++++++++++++++++++++----------- src/main.rs | 22 ++++++++++++++++--- src/network.rs | 6 +++++- src/ui.rs | 28 ++++++++++++++++-------- 4 files changed, 88 insertions(+), 26 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8348dda..5c97b26 100644 --- a/src/app.rs +++ b/src/app.rs @@ -170,6 +170,8 @@ pub struct App { // Torrent list state pub torrent_selected: usize, + pub filtered_indices: Vec, // cached indices into torrents + pub needs_filter_recalc: bool, // Details panel state pub show_details: bool, @@ -243,6 +245,8 @@ impl App { sidebar_selected: 0, torrent_selected: 0, + filtered_indices: Vec::new(), + needs_filter_recalc: true, show_details: false, details_tab: DetailsTab::General, @@ -297,15 +301,16 @@ impl App { self.sidebar_selected = self.sidebar_selected.min(self.sidebar_items.len().saturating_sub(1)); } - /// Return the torrents visible after applying sidebar filter, search and sort. - pub fn filtered_torrents(&self) -> Vec<&Torrent> { + /// Recalculate and cache filtered + sorted indices. + pub fn recalc_filtered(&mut self) { let filter = self.sidebar_items.get(self.sidebar_selected); let search = self.search_input.value().to_lowercase(); - let mut filtered: Vec<&Torrent> = self + let mut filtered: Vec<(usize, &Torrent)> = self .torrents .iter() - .filter(|t| match filter { + .enumerate() + .filter(|(_, t)| match filter { Some(SidebarFilter::All) => true, Some(SidebarFilter::Downloading) => { t.status == "downloading" @@ -329,7 +334,7 @@ impl App { Some(SidebarFilter::Tag(tag)) => t.tags.contains(tag), None => true, }) - .filter(|t| { + .filter(|(_, t)| { if search.is_empty() { true } else { @@ -338,7 +343,7 @@ impl App { }) .collect(); - filtered.sort_by(|a, b| { + filtered.sort_by(|(_, a), (_, b)| { let ord = match self.sort_column { SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), SortColumn::Size => a.size.cmp(&b.size), @@ -354,19 +359,41 @@ impl App { if self.sort_ascending { ord } else { ord.reverse() } }); - filtered + self.filtered_indices = filtered.into_iter().map(|(i, _)| i).collect(); + self.needs_filter_recalc = false; + + // Purge stale selected hashes + let valid_hashes: HashSet = self.torrents.iter().map(|t| t.hash.clone()).collect(); + self.selected_hashes.retain(|h| valid_hashes.contains(h)); + } + + /// Return the torrents visible after applying sidebar filter, search and sort. + pub fn filtered_torrents(&mut self) -> Vec<&Torrent> { + if self.needs_filter_recalc { + self.recalc_filtered(); + } + self.filtered_indices.iter() + .filter_map(|&i| self.torrents.get(i)) + .collect() } /// Get the hash of the torrent currently selected in the *filtered* list. - pub fn selected_torrent_hash(&self) -> Option { - let filtered = self.filtered_torrents(); - filtered.get(self.torrent_selected).map(|t| t.hash.clone()) + pub fn selected_torrent_hash(&mut self) -> Option { + if self.needs_filter_recalc { + self.recalc_filtered(); + } + self.filtered_indices.get(self.torrent_selected) + .and_then(|&i| self.torrents.get(i)) + .map(|t| t.hash.clone()) } /// Get the currently selected torrent (filtered view). - pub fn selected_torrent(&self) -> Option<&Torrent> { - let filtered = self.filtered_torrents(); - filtered.get(self.torrent_selected).copied() + pub fn selected_torrent(&mut self) -> Option<&Torrent> { + if self.needs_filter_recalc { + self.recalc_filtered(); + } + self.filtered_indices.get(self.torrent_selected) + .and_then(|&i| self.torrents.get(i)) } /// Process an incoming NetworkEvent and mutate state accordingly. @@ -374,6 +401,7 @@ impl App { match event { NetworkEvent::TorrentList(list) => { self.torrents = list; + self.needs_filter_recalc = true; let count = self.filtered_torrents().len(); if count > 0 { self.torrent_selected = self.torrent_selected.min(count - 1); @@ -458,6 +486,7 @@ impl App { if !self.sidebar_items.is_empty() { self.sidebar_selected = (self.sidebar_selected + 1) % self.sidebar_items.len(); self.torrent_selected = 0; + self.needs_filter_recalc = true; } } @@ -465,6 +494,7 @@ impl App { if !self.sidebar_items.is_empty() { self.sidebar_selected = self.sidebar_selected.saturating_sub(1); self.torrent_selected = 0; + self.needs_filter_recalc = true; } } @@ -515,10 +545,12 @@ impl App { pub fn next_sort_column(&mut self) { self.sort_column = self.sort_column.next(); + self.needs_filter_recalc = true; } pub fn toggle_sort_direction(&mut self) { self.sort_ascending = !self.sort_ascending; + self.needs_filter_recalc = true; } pub fn quit(&mut self) { diff --git a/src/main.rs b/src/main.rs index d6cc959..504436c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -185,7 +185,7 @@ fn draw_and_cursor( } /// Send tracker/file/property fetch commands when the details panel is open. -async fn refresh_details(app: &App, cmd_tx: &mpsc::Sender) { +async fn refresh_details(app: &mut App, cmd_tx: &mpsc::Sender) { if !app.show_details { return; } @@ -276,12 +276,14 @@ async fn handle_input( KeyCode::Esc => { app.show_search = false; app.search_input.reset(); + app.needs_filter_recalc = true; } KeyCode::Enter => { app.show_search = false; } _ => { app.search_input.handle_event(&evt); + app.needs_filter_recalc = true; } } return Ok(()); @@ -335,6 +337,9 @@ async fn handle_input( app::AddModalFocus::Category => {} app::AddModalFocus::Tags => {} } + // Space toggles advanced options regardless of focus + app.add_paused = !app.add_paused; + app.status_message = Some(format!("Add paused: {}", if app.add_paused { "Yes" } else { "No" })); } _ => { match app.add_modal_focus { @@ -513,8 +518,19 @@ async fn handle_input( KeyCode::Char('p') => { if !app.selected_hashes.is_empty() { let hashes: Vec = app.selected_hashes.iter().cloned().collect(); - app.status_message = Some(format!("Pausing {} torrent(s)...", hashes.len())); - let _ = cmd_tx.send(Command::Pause(hashes)).await; + // Check majority state of selected torrents + let paused_count = app.torrents.iter() + .filter(|t| app.selected_hashes.contains(&t.hash)) + .filter(|t| t.status == "pausedDL" || t.status == "pausedUP" || t.status == "stoppedDL" || t.status == "stoppedUP") + .count(); + let is_majority_paused = paused_count * 2 >= hashes.len(); + let (cmd, msg) = if is_majority_paused { + (Command::Resume(hashes.clone()), format!("Resuming {} torrent(s)...", hashes.len())) + } else { + (Command::Pause(hashes.clone()), format!("Pausing {} torrent(s)...", hashes.len())) + }; + app.status_message = Some(msg); + let _ = cmd_tx.send(cmd).await; } else if let Some(torrent) = app.selected_torrent() { let hash = torrent.hash.clone(); let is_paused = torrent.status == "pausedDL" diff --git a/src/network.rs b/src/network.rs index ca0db68..443bcc4 100644 --- a/src/network.rs +++ b/src/network.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::{mpsc::{Receiver, Sender}, Semaphore}; use tokio::time::interval; use crate::events::{Command, NetworkEvent}; @@ -48,11 +48,15 @@ pub fn spawn_network_worker( match client.get_torrents().await { Ok(mut torrents) => { // Enrich torrents with properties (popularity + seed/leech counts) + // Limited to 10 concurrent requests to avoid overwhelming qBittorrent + let semaphore = std::sync::Arc::new(Semaphore::new(10)); let mut set = tokio::task::JoinSet::new(); for torrent in &torrents { let hash = torrent.hash.clone(); let c = client.clone(); + let permit = semaphore.clone().acquire_owned().await.unwrap(); set.spawn(async move { + let _permit = permit; match c.get_torrent_properties(&hash).await { Ok(props) => Some((hash, props)), Err(_) => None, diff --git a/src/ui.rs b/src/ui.rs index 1eb11ec..d7e72f9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -83,7 +83,7 @@ pub fn draw(f: &mut Frame, app: &mut App, cursor_pos: &mut Option<(u16, u16)>) { .split(f.size()); draw_header(f, app, chunks[0]); - draw_body(f, app, chunks[1]); + draw_body(f, app, chunks[1], cursor_pos); draw_footer(f, app, chunks[2]); if app.show_add_modal { @@ -134,7 +134,7 @@ fn draw_header(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { f.render_widget(free, cols[2]); } -fn draw_body(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { +fn draw_body(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect, cursor_pos: &mut Option<(u16, u16)>) { let body_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) @@ -149,7 +149,7 @@ fn draw_body(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(5)]) .split(right_area); - draw_search_bar(f, app, search_chunks[0]); + draw_search_bar(f, app, search_chunks[0], cursor_pos); if app.show_details { let detail_chunks = Layout::default() @@ -175,7 +175,7 @@ fn draw_body(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { } } -fn draw_search_bar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { +fn draw_search_bar(f: &mut Frame, app: &App, area: ratatui::layout::Rect, cursor_pos: &mut Option<(u16, u16)>) { let block = Block::default() .borders(Borders::ALL) .title(" Search ") @@ -184,6 +184,9 @@ fn draw_search_bar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { f.render_widget(block, area); let text = Paragraph::new(app.search_input.value()); f.render_widget(text, inner); + let x = inner.x + app.search_input.visual_cursor() as u16; + let y = inner.y; + *cursor_pos = Some((x, y)); } fn draw_sidebar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { @@ -220,7 +223,7 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { f.render_widget(list, area); } -fn draw_torrent_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { +fn draw_torrent_list(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { let header_cells = ["Name", "Size", "Progress", "Status", "Ratio", "DL", "UL", "Seeds", "Leechers", "Popularity"] .iter() .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))); @@ -228,10 +231,17 @@ fn draw_torrent_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { .style(Style::default().fg(Color::Yellow)) .height(1); - let filtered = app.filtered_torrents(); - let rows: Vec = filtered + if app.needs_filter_recalc { + app.recalc_filtered(); + } + let indices = app.filtered_indices.clone(); + let selected = app.torrent_selected; + let selected_hashes = app.selected_hashes.clone(); + + let rows: Vec = indices .iter() .enumerate() + .filter_map(|(i, &idx)| app.torrents.get(idx).map(|t| (i, t))) .map(|(i, t)| { let progress = format!("{:.1}%", t.progress * 100.0); let size = format_size(t.size as u64, DECIMAL); @@ -273,8 +283,8 @@ fn draw_torrent_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { Cell::from(popularity), ]; - let is_selected = app.selected_hashes.contains(&t.hash); - let style = if i == app.torrent_selected { + let is_selected = selected_hashes.contains(&t.hash); + let style = if i == selected { if is_selected { Style::default() .bg(Color::Blue)