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:
parent
f4b3f20602
commit
646a4cd878
58
src/app.rs
58
src/app.rs
|
|
@ -170,6 +170,8 @@ pub struct App {
|
|||
|
||||
// Torrent list state
|
||||
pub torrent_selected: usize,
|
||||
pub filtered_indices: Vec<usize>, // 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<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.
|
||||
pub fn selected_torrent_hash(&self) -> Option<String> {
|
||||
let filtered = self.filtered_torrents();
|
||||
filtered.get(self.torrent_selected).map(|t| t.hash.clone())
|
||||
pub fn selected_torrent_hash(&mut self) -> Option<String> {
|
||||
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) {
|
||||
|
|
|
|||
22
src/main.rs
22
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<Command>) {
|
||||
async fn refresh_details(app: &mut App, cmd_tx: &mpsc::Sender<Command>) {
|
||||
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<String> = 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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
28
src/ui.rs
28
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<Row> = 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<Row> = 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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue