Fix review issues: cache filtered torrents, purge stale selections, multi-select toggle, search cursor, add modal space, limit concurrent API calls

This commit is contained in:
ewen 2026-05-11 15:08:06 +02:00
parent f4b3f20602
commit 646a4cd878
4 changed files with 88 additions and 26 deletions

View file

@ -170,6 +170,8 @@ pub struct App {
// Torrent list state // Torrent list state
pub torrent_selected: usize, pub torrent_selected: usize,
pub filtered_indices: Vec<usize>, // cached indices into torrents
pub needs_filter_recalc: bool,
// Details panel state // Details panel state
pub show_details: bool, pub show_details: bool,
@ -243,6 +245,8 @@ impl App {
sidebar_selected: 0, sidebar_selected: 0,
torrent_selected: 0, torrent_selected: 0,
filtered_indices: Vec::new(),
needs_filter_recalc: true,
show_details: false, show_details: false,
details_tab: DetailsTab::General, details_tab: DetailsTab::General,
@ -297,15 +301,16 @@ impl App {
self.sidebar_selected = self.sidebar_selected.min(self.sidebar_items.len().saturating_sub(1)); 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. /// Recalculate and cache filtered + sorted indices.
pub fn filtered_torrents(&self) -> Vec<&Torrent> { pub fn recalc_filtered(&mut self) {
let filter = self.sidebar_items.get(self.sidebar_selected); let filter = self.sidebar_items.get(self.sidebar_selected);
let search = self.search_input.value().to_lowercase(); let search = self.search_input.value().to_lowercase();
let mut filtered: Vec<&Torrent> = self let mut filtered: Vec<(usize, &Torrent)> = self
.torrents .torrents
.iter() .iter()
.filter(|t| match filter { .enumerate()
.filter(|(_, t)| match filter {
Some(SidebarFilter::All) => true, Some(SidebarFilter::All) => true,
Some(SidebarFilter::Downloading) => { Some(SidebarFilter::Downloading) => {
t.status == "downloading" t.status == "downloading"
@ -329,7 +334,7 @@ impl App {
Some(SidebarFilter::Tag(tag)) => t.tags.contains(tag), Some(SidebarFilter::Tag(tag)) => t.tags.contains(tag),
None => true, None => true,
}) })
.filter(|t| { .filter(|(_, t)| {
if search.is_empty() { if search.is_empty() {
true true
} else { } else {
@ -338,7 +343,7 @@ impl App {
}) })
.collect(); .collect();
filtered.sort_by(|a, b| { filtered.sort_by(|(_, a), (_, b)| {
let ord = match self.sort_column { let ord = match self.sort_column {
SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortColumn::Size => a.size.cmp(&b.size), SortColumn::Size => a.size.cmp(&b.size),
@ -354,19 +359,41 @@ impl App {
if self.sort_ascending { ord } else { ord.reverse() } 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<String> = 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. /// Get the hash of the torrent currently selected in the *filtered* list.
pub fn selected_torrent_hash(&self) -> Option<String> { pub fn selected_torrent_hash(&mut self) -> Option<String> {
let filtered = self.filtered_torrents(); if self.needs_filter_recalc {
filtered.get(self.torrent_selected).map(|t| t.hash.clone()) 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). /// Get the currently selected torrent (filtered view).
pub fn selected_torrent(&self) -> Option<&Torrent> { pub fn selected_torrent(&mut self) -> Option<&Torrent> {
let filtered = self.filtered_torrents(); if self.needs_filter_recalc {
filtered.get(self.torrent_selected).copied() 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. /// Process an incoming NetworkEvent and mutate state accordingly.
@ -374,6 +401,7 @@ impl App {
match event { match event {
NetworkEvent::TorrentList(list) => { NetworkEvent::TorrentList(list) => {
self.torrents = list; self.torrents = list;
self.needs_filter_recalc = true;
let count = self.filtered_torrents().len(); let count = self.filtered_torrents().len();
if count > 0 { if count > 0 {
self.torrent_selected = self.torrent_selected.min(count - 1); self.torrent_selected = self.torrent_selected.min(count - 1);
@ -458,6 +486,7 @@ impl App {
if !self.sidebar_items.is_empty() { if !self.sidebar_items.is_empty() {
self.sidebar_selected = (self.sidebar_selected + 1) % self.sidebar_items.len(); self.sidebar_selected = (self.sidebar_selected + 1) % self.sidebar_items.len();
self.torrent_selected = 0; self.torrent_selected = 0;
self.needs_filter_recalc = true;
} }
} }
@ -465,6 +494,7 @@ impl App {
if !self.sidebar_items.is_empty() { if !self.sidebar_items.is_empty() {
self.sidebar_selected = self.sidebar_selected.saturating_sub(1); self.sidebar_selected = self.sidebar_selected.saturating_sub(1);
self.torrent_selected = 0; self.torrent_selected = 0;
self.needs_filter_recalc = true;
} }
} }
@ -515,10 +545,12 @@ impl App {
pub fn next_sort_column(&mut self) { pub fn next_sort_column(&mut self) {
self.sort_column = self.sort_column.next(); self.sort_column = self.sort_column.next();
self.needs_filter_recalc = true;
} }
pub fn toggle_sort_direction(&mut self) { pub fn toggle_sort_direction(&mut self) {
self.sort_ascending = !self.sort_ascending; self.sort_ascending = !self.sort_ascending;
self.needs_filter_recalc = true;
} }
pub fn quit(&mut self) { pub fn quit(&mut self) {

View file

@ -185,7 +185,7 @@ fn draw_and_cursor(
} }
/// Send tracker/file/property fetch commands when the details panel is open. /// Send tracker/file/property fetch commands when the details panel is open.
async fn refresh_details(app: &App, cmd_tx: &mpsc::Sender<Command>) { async fn refresh_details(app: &mut App, cmd_tx: &mpsc::Sender<Command>) {
if !app.show_details { if !app.show_details {
return; return;
} }
@ -276,12 +276,14 @@ async fn handle_input(
KeyCode::Esc => { KeyCode::Esc => {
app.show_search = false; app.show_search = false;
app.search_input.reset(); app.search_input.reset();
app.needs_filter_recalc = true;
} }
KeyCode::Enter => { KeyCode::Enter => {
app.show_search = false; app.show_search = false;
} }
_ => { _ => {
app.search_input.handle_event(&evt); app.search_input.handle_event(&evt);
app.needs_filter_recalc = true;
} }
} }
return Ok(()); return Ok(());
@ -335,6 +337,9 @@ async fn handle_input(
app::AddModalFocus::Category => {} app::AddModalFocus::Category => {}
app::AddModalFocus::Tags => {} 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 { match app.add_modal_focus {
@ -513,8 +518,19 @@ async fn handle_input(
KeyCode::Char('p') => { KeyCode::Char('p') => {
if !app.selected_hashes.is_empty() { if !app.selected_hashes.is_empty() {
let hashes: Vec<String> = app.selected_hashes.iter().cloned().collect(); let hashes: Vec<String> = app.selected_hashes.iter().cloned().collect();
app.status_message = Some(format!("Pausing {} torrent(s)...", hashes.len())); // Check majority state of selected torrents
let _ = cmd_tx.send(Command::Pause(hashes)).await; 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() { } else if let Some(torrent) = app.selected_torrent() {
let hash = torrent.hash.clone(); let hash = torrent.hash.clone();
let is_paused = torrent.status == "pausedDL" let is_paused = torrent.status == "pausedDL"

View file

@ -1,6 +1,6 @@
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::{mpsc::{Receiver, Sender}, Semaphore};
use tokio::time::interval; use tokio::time::interval;
use crate::events::{Command, NetworkEvent}; use crate::events::{Command, NetworkEvent};
@ -48,11 +48,15 @@ pub fn spawn_network_worker(
match client.get_torrents().await { match client.get_torrents().await {
Ok(mut torrents) => { Ok(mut torrents) => {
// Enrich torrents with properties (popularity + seed/leech counts) // 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(); let mut set = tokio::task::JoinSet::new();
for torrent in &torrents { for torrent in &torrents {
let hash = torrent.hash.clone(); let hash = torrent.hash.clone();
let c = client.clone(); let c = client.clone();
let permit = semaphore.clone().acquire_owned().await.unwrap();
set.spawn(async move { set.spawn(async move {
let _permit = permit;
match c.get_torrent_properties(&hash).await { match c.get_torrent_properties(&hash).await {
Ok(props) => Some((hash, props)), Ok(props) => Some((hash, props)),
Err(_) => None, Err(_) => None,

View file

@ -83,7 +83,7 @@ pub fn draw(f: &mut Frame, app: &mut App, cursor_pos: &mut Option<(u16, u16)>) {
.split(f.size()); .split(f.size());
draw_header(f, app, chunks[0]); 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]); draw_footer(f, app, chunks[2]);
if app.show_add_modal { 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]); 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() let body_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) .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) .direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)]) .constraints([Constraint::Length(3), Constraint::Min(5)])
.split(right_area); .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 { if app.show_details {
let detail_chunks = Layout::default() 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() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(" Search ") .title(" Search ")
@ -184,6 +184,9 @@ fn draw_search_bar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
f.render_widget(block, area); f.render_widget(block, area);
let text = Paragraph::new(app.search_input.value()); let text = Paragraph::new(app.search_input.value());
f.render_widget(text, inner); 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) { 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); 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"] let header_cells = ["Name", "Size", "Progress", "Status", "Ratio", "DL", "UL", "Seeds", "Leechers", "Popularity"]
.iter() .iter()
.map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD))); .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)) .style(Style::default().fg(Color::Yellow))
.height(1); .height(1);
let filtered = app.filtered_torrents(); if app.needs_filter_recalc {
let rows: Vec<Row> = filtered app.recalc_filtered();
}
let indices = app.filtered_indices.clone();
let selected = app.torrent_selected;
let selected_hashes = app.selected_hashes.clone();
let rows: Vec<Row> = indices
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(i, &idx)| app.torrents.get(idx).map(|t| (i, t)))
.map(|(i, t)| { .map(|(i, t)| {
let progress = format!("{:.1}%", t.progress * 100.0); let progress = format!("{:.1}%", t.progress * 100.0);
let size = format_size(t.size as u64, DECIMAL); 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), Cell::from(popularity),
]; ];
let is_selected = app.selected_hashes.contains(&t.hash); let is_selected = selected_hashes.contains(&t.hash);
let style = if i == app.torrent_selected { let style = if i == selected {
if is_selected { if is_selected {
Style::default() Style::default()
.bg(Color::Blue) .bg(Color::Blue)