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
|
// 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) {
|
||||||
|
|
|
||||||
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.
|
/// 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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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());
|
.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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue