From afdc34e131b55493c70c887944b5d8dfd90f3871 Mon Sep 17 00:00:00 2001 From: spinline Date: Mon, 9 Feb 2026 00:39:44 +0300 Subject: [PATCH] perf: use keyed and fine-grained reactivity in torrent table --- frontend/src/components/torrent/table.rs | 484 ++++++++++++++--------- 1 file changed, 293 insertions(+), 191 deletions(-) diff --git a/frontend/src/components/torrent/table.rs b/frontend/src/components/torrent/table.rs index 1fdc28f..134f995 100644 --- a/frontend/src/components/torrent/table.rs +++ b/frontend/src/components/torrent/table.rs @@ -81,75 +81,77 @@ pub fn TorrentTable() -> impl IntoView { let sort_col = create_rw_signal(SortColumn::AddedDate); let sort_dir = create_rw_signal(SortDirection::Descending); - let filtered_torrents = move || { - // Convert HashMap values to Vec for filtering and sorting - let torrents: Vec = store.torrents.with(|map| map.values().cloned().collect()); + // Get sorted and filtered hashes only + let filtered_hashes = move || { + store.torrents.with(|map| { + let mut torrents: Vec<&shared::Torrent> = map + .values() + .filter(|t| { + let filter = store.filter.get(); + let search = store.search_query.get().to_lowercase(); - let mut torrents = torrents - .into_iter() - .filter(|t| { - let filter = store.filter.get(); - let search = store.search_query.get().to_lowercase(); + let matches_filter = match filter { + crate::store::FilterStatus::All => true, + crate::store::FilterStatus::Downloading => { + t.status == shared::TorrentStatus::Downloading + } + crate::store::FilterStatus::Seeding => { + t.status == shared::TorrentStatus::Seeding + } + crate::store::FilterStatus::Completed => { + t.status == shared::TorrentStatus::Seeding + || (t.status == shared::TorrentStatus::Paused + && t.percent_complete >= 100.0) + } + crate::store::FilterStatus::Paused => { + t.status == shared::TorrentStatus::Paused + } + crate::store::FilterStatus::Inactive => { + t.status == shared::TorrentStatus::Paused + || t.status == shared::TorrentStatus::Error + } + _ => true, + }; - let matches_filter = match filter { - crate::store::FilterStatus::All => true, - crate::store::FilterStatus::Downloading => { - t.status == shared::TorrentStatus::Downloading + let matches_search = if search.is_empty() { + true + } else { + t.name.to_lowercase().contains(&search) + }; + + matches_filter && matches_search + }) + .collect(); + + torrents.sort_by(|a, b| { + let col = sort_col.get(); + let dir = sort_dir.get(); + let cmp = match col { + SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + SortColumn::Size => a.size.cmp(&b.size), + SortColumn::Progress => a + .percent_complete + .partial_cmp(&b.percent_complete) + .unwrap_or(std::cmp::Ordering::Equal), + SortColumn::Status => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)), + SortColumn::DownSpeed => a.down_rate.cmp(&b.down_rate), + SortColumn::UpSpeed => a.up_rate.cmp(&b.up_rate), + SortColumn::ETA => { + let a_eta = if a.eta <= 0 { i64::MAX } else { a.eta }; + let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta }; + a_eta.cmp(&b_eta) } - crate::store::FilterStatus::Seeding => { - t.status == shared::TorrentStatus::Seeding - } - crate::store::FilterStatus::Completed => { - t.status == shared::TorrentStatus::Seeding - || (t.status == shared::TorrentStatus::Paused - && t.percent_complete >= 100.0) - } // Approximate - crate::store::FilterStatus::Paused => t.status == shared::TorrentStatus::Paused, - crate::store::FilterStatus::Inactive => { - t.status == shared::TorrentStatus::Paused - || t.status == shared::TorrentStatus::Error - } - _ => true, + SortColumn::AddedDate => a.added_date.cmp(&b.added_date), }; - - let matches_search = if search.is_empty() { - true + if dir == SortDirection::Descending { + cmp.reverse() } else { - t.name.to_lowercase().contains(&search) - }; - - matches_filter && matches_search - }) - .collect::>(); - - torrents.sort_by(|a, b| { - let col = sort_col.get(); - let dir = sort_dir.get(); - let cmp = match col { - SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), - SortColumn::Size => a.size.cmp(&b.size), - SortColumn::Progress => a - .percent_complete - .partial_cmp(&b.percent_complete) - .unwrap_or(std::cmp::Ordering::Equal), - SortColumn::Status => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)), - SortColumn::DownSpeed => a.down_rate.cmp(&b.down_rate), - SortColumn::UpSpeed => a.up_rate.cmp(&b.up_rate), - SortColumn::ETA => { - let a_eta = if a.eta <= 0 { i64::MAX } else { a.eta }; - let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta }; - a_eta.cmp(&b_eta) + cmp } - SortColumn::AddedDate => a.added_date.cmp(&b.added_date), - }; - if dir == SortDirection::Descending { - cmp.reverse() - } else { - cmp - } - }); + }); - torrents + torrents.into_iter().map(|t| t.hash.clone()).collect::>() + }) }; let handle_sort = move |col: SortColumn| { @@ -268,124 +270,231 @@ pub fn TorrentTable() -> impl IntoView { - {move || filtered_torrents().into_iter().map(|t| { - let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" }; - let status_str = format!("{:?}", t.status); - let status_class = match t.status { - shared::TorrentStatus::Seeding => "text-success", - shared::TorrentStatus::Downloading => "text-primary", - shared::TorrentStatus::Paused => "text-warning", - shared::TorrentStatus::Error => "text-error", - _ => "text-base-content/50" - }; - let t_hash = t.hash.clone(); - let t_hash_click = t.hash.clone(); - - let is_selected_fn = move || { - selected_hash.get() == Some(t_hash.clone()) - }; - - view! { - } - on:contextmenu={ - let t_hash = t_hash_click.clone(); - move |e: web_sys::MouseEvent| handle_context_menu(e, t_hash.clone()) - } - on:click={ - let t_hash = t_hash_click.clone(); - move |_| set_selected_hash.set(Some(t_hash.clone())) - } - > - - {t.name} - - {format_bytes(t.size)} - -
- - {format!("{:.1}%", t.percent_complete)} -
- - {status_str} - {format_speed(t.down_rate)} - {format_speed(t.up_rate)} - {format_duration(t.eta)} - {format_date(t.added_date)} - + } } - }).collect::>()} + /> -
-
- "Torrents" +
+
+ "Torrents" -
+ view! { +
  • + +
  • + } + }).collect::>() + } + + +
    + +
    + + } + } + } + /> +
    +
    + + + + +
    + } +} + +#[component] +fn TorrentRow( + hash: String, + selected_hash: ReadSignal>, + set_selected_hash: WriteSignal>, + on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone, +) -> impl IntoView { + let store = use_context::().expect("store not provided"); + + let h = hash.clone(); + // Memoized access to the specific torrent data. + // This only re-renders the row if this specific torrent actually changes. + let torrent = create_memo(move |_| { + store.torrents.with(|map| map.get(&h).cloned()) + }); + + view! { + + { + let on_context_menu = on_context_menu.clone(); + let hash = hash.clone(); + + move || { + let t = torrent.get().unwrap(); + let t_hash = hash.clone(); + let t_hash_class = t_hash.clone(); + let on_context_menu = on_context_menu.clone(); + + let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" }; + let status_str = format!("{:?}", t.status); + let status_class = match t.status { + shared::TorrentStatus::Seeding => "text-success", + shared::TorrentStatus::Downloading => "text-primary", + shared::TorrentStatus::Paused => "text-warning", + shared::TorrentStatus::Error => "text-error", + _ => "text-base-content/50" + }; + + view! { + + + {t.name} + + {format_bytes(t.size)} + +
    + + {format!("{:.1}%", t.percent_complete)} +
    + + {status_str} + {format_speed(t.down_rate)} + {format_speed(t.up_rate)} + {format_duration(t.eta)} + {format_date(t.added_date)} + + } + } + } +
    + } +} + +#[component] +fn TorrentCard( + hash: String, + selected_hash: ReadSignal>, + set_selected_hash: WriteSignal>, + set_menu_position: WriteSignal<(i32, i32)>, + set_menu_visible: WriteSignal, + on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone, +) -> impl IntoView { + let store = use_context::().expect("store not provided"); + + let h = hash.clone(); + let torrent = create_memo(move |_| { + store.torrents.with(|map| map.get(&h).cloned()) + }); + + view! { + + { + let hash = hash.clone(); + let on_context_menu = on_context_menu.clone(); + + move || { + let t = torrent.get().unwrap(); + let t_hash = hash.clone(); + let t_hash_class = t_hash.clone(); + let on_context_menu = on_context_menu.clone(); -
    {move || filtered_torrents().into_iter().map(|t| { let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" }; let status_str = format!("{:?}", t.status); let status_badge_class = match t.status { @@ -395,10 +504,8 @@ pub fn TorrentTable() -> impl IntoView { shared::TorrentStatus::Error => "badge-error badge-soft", _ => "badge-ghost" }; - let _t_hash = t.hash.clone(); - let t_hash_click = t.hash.clone(); - let t_hash_long = t.hash.clone(); + let t_hash_long = t_hash.clone(); let leptos_use::UseTimeoutFnReturn { start, stop, .. } = use_timeout_fn( move |pos: (i32, i32)| { set_menu_position.set(pos); @@ -440,15 +547,21 @@ pub fn TorrentTable() -> impl IntoView { view! {
    impl IntoView {
    } - }).collect::>()} - - - - - - - + } + } +
    } }