Compare commits

...

2 Commits

Author SHA1 Message Date
spinline
5a8f5169ea perf: implement smart merge logic for FullList to preserve reactive references
All checks were successful
Build MIPS Binary / build (push) Successful in 4m22s
2026-02-09 00:40:09 +03:00
spinline
afdc34e131 perf: use keyed <For /> and fine-grained reactivity in torrent table
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-09 00:39:44 +03:00
2 changed files with 313 additions and 196 deletions

View File

@@ -81,12 +81,11 @@ 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<shared::Torrent> = store.torrents.with(|map| map.values().cloned().collect());
let mut torrents = torrents
.into_iter()
// 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();
@@ -103,8 +102,10 @@ pub fn TorrentTable() -> impl IntoView {
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::Paused => {
t.status == shared::TorrentStatus::Paused
}
crate::store::FilterStatus::Inactive => {
t.status == shared::TorrentStatus::Paused
|| t.status == shared::TorrentStatus::Error
@@ -120,7 +121,7 @@ pub fn TorrentTable() -> impl IntoView {
matches_filter && matches_search
})
.collect::<Vec<_>>();
.collect();
torrents.sort_by(|a, b| {
let col = sort_col.get();
@@ -149,7 +150,8 @@ pub fn TorrentTable() -> impl IntoView {
}
});
torrents
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
})
};
let handle_sort = move |col: SortColumn| {
@@ -268,60 +270,23 @@ pub fn TorrentTable() -> impl IntoView {
</tr>
</thead>
<tbody>
{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())
};
<For
each=move || filtered_hashes()
key=|hash| hash.clone()
children={
let handle_context_menu = handle_context_menu.clone();
move |hash| {
view! {
<tr
class=move || {
let base = "hover border-b border-base-200 select-none";
if is_selected_fn() {
format!("{} bg-primary/10", base)
} else {
base.to_string()
<TorrentRow
hash=hash.clone()
selected_hash=selected_hash
set_selected_hash=set_selected_hash
on_context_menu=handle_context_menu.clone()
/>
}
}
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()))
}
>
<td class="font-medium truncate max-w-xs" title={t.name.clone()}>
{t.name}
</td>
<td class="opacity-80 font-mono text-[11px]">{format_bytes(t.size)}</td>
<td>
<div class="flex items-center gap-2">
<progress class={format!("progress w-24 {}", progress_class)} value={t.percent_complete} max="100"></progress>
<span class="text-[10px] opacity-70">{format!("{:.1}%", t.percent_complete)}</span>
</div>
</td>
<td class={format!("text-[11px] font-medium {}", status_class)}>{status_str}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-success">{format_speed(t.down_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-primary">{format_speed(t.up_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80">{format_duration(t.eta)}</td>
<td class="text-right font-mono text-[11px] opacity-80 whitespace-nowrap">{format_date(t.added_date)}</td>
</tr>
}
}).collect::<Vec<_>>()}
/>
</tbody>
</table>
</div>
@@ -385,7 +350,151 @@ pub fn TorrentTable() -> impl IntoView {
</details>
</div>
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer"> {move || filtered_torrents().into_iter().map(|t| {
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer">
<For
each=move || filtered_hashes()
key=|hash| hash.clone()
children={
let handle_context_menu = handle_context_menu.clone();
move |hash| {
view! {
<TorrentCard
hash=hash.clone()
selected_hash=selected_hash
set_selected_hash=set_selected_hash
set_menu_position=set_menu_position
set_menu_visible=set_menu_visible
on_context_menu=handle_context_menu.clone()
/>
}
}
}
/>
</div>
</div>
<Show when=move || menu_visible.get() fallback=|| ()>
<crate::components::context_menu::ContextMenu
visible=true
position=menu_position.get()
torrent_hash=selected_hash.get().unwrap_or_default()
on_close=Callback::from(move |_| set_menu_visible.set(false))
on_action=Callback::from(on_action)
/>
</Show>
</div>
}
}
#[component]
fn TorrentRow(
hash: String,
selected_hash: ReadSignal<Option<String>>,
set_selected_hash: WriteSignal<Option<String>>,
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().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! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
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! {
<tr
class=move || {
let base = "hover border-b border-base-200 select-none";
if selected_hash.get() == Some(t_hash_class.clone()) {
format!("{} bg-primary/10", base)
} else {
base.to_string()
}
}
on:contextmenu={
let t_hash = t_hash.clone();
let on_context_menu = on_context_menu.clone();
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
}
on:click={
let t_hash = t_hash.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
>
<td class="font-medium truncate max-w-xs" title={t.name.clone()}>
{t.name}
</td>
<td class="opacity-80 font-mono text-[11px]">{format_bytes(t.size)}</td>
<td>
<div class="flex items-center gap-2">
<progress class={format!("progress w-24 {}", progress_class)} value={t.percent_complete} max="100"></progress>
<span class="text-[10px] opacity-70">{format!("{:.1}%", t.percent_complete)}</span>
</div>
</td>
<td class={format!("text-[11px] font-medium {}", status_class)}>{status_str}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-success">{format_speed(t.down_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80 text-primary">{format_speed(t.up_rate)}</td>
<td class="text-right font-mono text-[11px] opacity-80">{format_duration(t.eta)}</td>
<td class="text-right font-mono text-[11px] opacity-80 whitespace-nowrap">{format_date(t.added_date)}</td>
</tr>
}
}
}
</Show>
}
}
#[component]
fn TorrentCard(
hash: String,
selected_hash: ReadSignal<Option<String>>,
set_selected_hash: WriteSignal<Option<String>>,
set_menu_position: WriteSignal<(i32, i32)>,
set_menu_visible: WriteSignal<bool>,
on_context_menu: impl Fn(web_sys::MouseEvent, String) + 'static + Clone,
) -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let h = hash.clone();
let torrent = create_memo(move |_| {
store.torrents.with(|map| map.get(&h).cloned())
});
view! {
<Show when=move || torrent.get().is_some() fallback=|| ()>
{
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();
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! {
<div
class=move || {
"card card-compact bg-base-100 shadow-sm border border-base-200 transition-transform active:scale-[0.99] select-none cursor-pointer"
let base = "card card-compact bg-base-100 shadow-sm border border-base-200 transition-transform active:scale-[0.99] select-none cursor-pointer";
if selected_hash.get() == Some(t_hash_class.clone()) {
format!("{} ring-2 ring-primary ring-inset", base)
} else {
base.to_string()
}
}
style="user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;"
on:contextmenu={
let t_hash = t.hash.clone();
move |e: web_sys::MouseEvent| handle_context_menu(e, t_hash.clone())
let t_hash = t_hash.clone();
let on_context_menu = on_context_menu.clone();
move |e: web_sys::MouseEvent| on_context_menu(e, t_hash.clone())
}
on:click={
let t_hash = t_hash_click.clone();
let t_hash = t_hash.clone();
move |_| set_selected_hash.set(Some(t_hash.clone()))
}
on:touchstart=handle_touchstart
@@ -493,19 +606,8 @@ pub fn TorrentTable() -> impl IntoView {
</div>
</div>
}
}).collect::<Vec<_>>()}
</div>
</div>
<Show when=move || menu_visible.get() fallback=|| ()>
<crate::components::context_menu::ContextMenu
visible=true
position=menu_position.get()
torrent_hash=selected_hash.get().unwrap_or_default()
on_close=Callback::from(move |_| set_menu_visible.set(false))
on_action=Callback::from(on_action)
/>
}
}
</Show>
</div>
}
}

View File

@@ -195,11 +195,26 @@ pub fn provide_torrent_store() {
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
match event {
AppEvent::FullList { torrents: list, .. } => {
let map: HashMap<String, Torrent> = list
.into_iter()
.map(|t| (t.hash.clone(), t))
.collect();
torrents.set(map);
torrents.update(|map| {
// 1. Create a set of new hashes for quick lookup
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
// 2. Remove torrents that are no longer in the list
map.retain(|hash, _| new_hashes.contains(hash));
// 3. Update or Insert torrents from the new list
for new_torrent in list {
if let Some(existing) = map.get_mut(&new_torrent.hash) {
// Only update if changed (Torrent derives PartialEq)
if existing != &new_torrent {
*existing = new_torrent;
}
} else {
// New torrent, insert it
map.insert(new_torrent.hash.clone(), new_torrent);
}
}
});
}
AppEvent::Update(update) => {
torrents.update(|map| {