feat(web): torrent list virtualization (#7674)

* add clusterize

* fix include clusterize css

* fix scroll jumping on very large list

* fix rapid fire of rpc calls when inspector is visible

* ensure detailed torrent data is fetched on selection changes too

* stop dispatching selection changed event on refilter

* remove duplicate event listener for torrent row clicks

* fix scroll into view and remove unnecessary stuff

* fix missing context menu

* fix missing row click

* set last torrent clicked id on right click

* remove unused variable

* Reduce clusterize row render count

* Remove excessive context_menu

* Tidy up a bit

* Restore unnecessary changes

* Apply prettier fix
This commit is contained in:
Ivan Kara
2026-02-15 14:17:01 +07:00
committed by GitHub
parent 1e68c41da8
commit 30bc84ebc9
6 changed files with 283 additions and 235 deletions

View File

@@ -490,6 +490,12 @@ a {
overflow: auto;
padding: 0;
right: 0;
// Override clusterize default max-height
&.clusterize-scroll {
max-height: none;
height: 100%;
}
}
#torrent-list {
@@ -501,6 +507,7 @@ a {
width: 100%;
user-select: none;
-webkit-user-select: none;
outline: none;
.torrent {
align-items: center;
@@ -1757,3 +1764,24 @@ dialog {
.upload-div {
display: none;
}
/// CLUSTERIZE
.clusterize-scroll {
max-height: 200px;
overflow: auto;
}
.clusterize-extra-row {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.clusterize-extra-row.clusterize-keep-parity {
display: none;
}
.clusterize-content {
outline: 0;
counter-reset: clusterize-counter;
}

7
web/package-lock.json generated
View File

@@ -7,6 +7,7 @@
"name": "transmission-web",
"license": "MIT",
"dependencies": {
"clusterize.js": "^1.0.0",
"fast-deep-equal": "^3.1.3"
},
"devDependencies": {
@@ -2197,6 +2198,12 @@
"node": ">=0.8.0"
}
},
"node_modules/clusterize.js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/clusterize.js/-/clusterize.js-1.0.0.tgz",
"integrity": "sha512-EEYhO8rOvw9JVaHLgEFdvvg9H6ug/GVl8KgakOoc9hg4FK6xmyYsC4B0Aw/QI6ClPxaGPKBetO+ISvCY8N/uUQ==",
"license": "MIT"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View File

@@ -34,6 +34,7 @@
"stylelint-config-sass-guidelines": "^12.1.0"
},
"dependencies": {
"clusterize.js": "^1.0.0",
"fast-deep-equal": "^3.1.3"
}
}

View File

@@ -30,7 +30,8 @@ export class Inspector extends EventTarget {
this.current_page = this.elements.info.root;
this.interval = setInterval(this._refreshTorrents.bind(this), 3000);
this.name = 'inspector';
this.selection_listener = (event_) => this._setTorrents(event_.selected);
this.selection_listener = (event_) =>
this._setTorrents(event_.selected, true);
this.torrent_listener = () => this._updateCurrentPage();
this.torrents = [];
this.file_torrent = null;
@@ -43,7 +44,7 @@ export class Inspector extends EventTarget {
'torrent-selection-changed',
this.selection_listener,
);
this._setTorrents(this.controller.getSelectedTorrents());
this._setTorrents(this.controller.getSelectedTorrents(), true); // Initial load
document.querySelector('#mainwin-workarea').append(this.elements.root);
}
@@ -219,7 +220,7 @@ export class Inspector extends EventTarget {
return { ...elements, ...pages };
}
_setTorrents(torrents) {
_setTorrents(torrents, isInitialLoad = false) {
// update the inspector when a selected torrent's data changes.
const key = 'dataChanged';
const callback = this.torrent_listener;
@@ -231,7 +232,11 @@ export class Inspector extends EventTarget {
t.addEventListener(key, callback);
}
this._refreshTorrents();
// Fetch detailed data immediately when requested
// (initial load or selection changes)
if (isInitialLoad) {
this._refreshTorrents();
}
this._updateCurrentPage();
}

View File

@@ -358,40 +358,3 @@ export class TorrentRendererCompact {
return root;
}
}
///
export class TorrentRow {
constructor(view, controller, torrent) {
this._view = view;
this._torrent = torrent;
this._element = view.createRow(torrent);
const update = () => this.render(controller);
this._torrent.addEventListener('dataChanged', update);
update();
}
getElement() {
return this._element;
}
render(controller) {
const tor = this.getTorrent();
if (tor) {
this._view.render(controller, tor, this.getElement());
}
}
isSelected() {
return this.getElement().classList.contains('selected');
}
getTorrent() {
return this._torrent;
}
getTorrentId() {
return this.getTorrent().getId();
}
}

View File

@@ -19,11 +19,7 @@ import { LabelsDialog } from './labels-dialog.js';
import { ShortcutsDialog } from './shortcuts-dialog.js';
import { StatisticsDialog } from './statistics-dialog.js';
import { Torrent } from './torrent.js';
import {
TorrentRow,
TorrentRendererCompact,
TorrentRendererFull,
} from './torrent-row.js';
import { TorrentRendererCompact, TorrentRendererFull } from './torrent-row.js';
import {
newOpts,
icon,
@@ -32,6 +28,7 @@ import {
setEnabled,
setTextContent,
} from './utils.js';
import Clusterize from 'clusterize.js';
export class Transmission extends EventTarget {
constructor(action_manager, notifications, prefs) {
@@ -73,13 +70,13 @@ export class Transmission extends EventTarget {
// Initialize the implementation fields
this.filterText = '';
this._torrents = {};
this._rows = [];
this._selectedTorrentIds = new Set();
this._torrentOrder = [];
this._clusterize = null;
this.oldTrackers = [];
this.dirtyTorrents = new Set();
this.changeStatus = false;
this.refilterSoon = debounce(() => this._refilter(false));
this.refilterAllSoon = debounce(() => this._refilter(true));
this.refilterAllSoon = debounce(() => this._refilter());
this.pointer_device = Object.seal({
is_touch_device: 'ontouchstart' in globalThis,
@@ -311,18 +308,28 @@ export class Transmission extends EventTarget {
this._setupSearchBox();
this.elements = {
torrent_container: document.querySelector('#torrent-container'),
torrent_list: document.querySelector('#torrent-list'),
};
// Setup clusterize for virtual scrolling
this._initializeClusterize();
const right_click = (event_) => {
// if not already, highlight the torrent
let row_element = event_.target;
while (row_element && !row_element.classList.contains('torrent')) {
row_element = row_element.parentNode;
}
const row = this._rows.find((r) => r.getElement() === row_element);
if (row && !row.isSelected()) {
this._setSelectedRow(row);
// Find torrent by data-torrent-id instead of row object
const torrentId = row_element?.dataset?.torrentId;
if (torrentId) {
const torrentIdNum = Number.parseInt(torrentId, 10);
if (!this._selectedTorrentIds.has(torrentIdNum)) {
this._setSelectedTorrent(torrentIdNum);
this._last_torrent_clicked = torrentIdNum;
}
}
if (this.handler) {
@@ -334,7 +341,12 @@ export class Transmission extends EventTarget {
event_.preventDefault();
};
// Set up click handlers
this.pointer_event(this.elements.torrent_list, right_click);
this.elements.torrent_list.addEventListener(
'click',
this._onRowClicked.bind(this),
);
// Get preferences & torrents from the daemon
this.loadDaemonPrefs();
@@ -352,6 +364,50 @@ export class Transmission extends EventTarget {
}
}
_initializeClusterize() {
// Initialize clusterize.js for virtual scrolling
this._clusterize = new Clusterize({
blocks_in_cluster: 4,
callbacks: {
clusterChanged: () => {
// Update selections on newly rendered rows
this._updateVisibleSelections();
},
},
contentId: 'torrent-list',
no_data_class: '',
no_data_text: '',
rows: ['<li></li>'],
rows_in_block: 25,
scrollId: 'torrent-container',
show_no_data_row: true,
tag: 'li',
});
}
_generateTorrentRowHTML(torrent) {
// Use existing renderers to create a temporary DOM element, then extract HTML
const isCompact = this.prefs.display_mode === Prefs.DisplayCompact;
const renderer = isCompact
? new TorrentRendererCompact()
: new TorrentRendererFull();
// Create temporary row using existing renderer
const tempRow = renderer.createRow(torrent);
tempRow.dataset.torrentId = torrent.getId();
// Add selection class if needed
if (this._selectedTorrentIds.has(torrent.getId())) {
tempRow.classList.add('selected');
}
// Render the content using existing renderer
renderer.render(this, torrent, tempRow);
// Return the HTML string
return tempRow.outerHTML;
}
_openTorrentFromUrl() {
setTimeout(() => {
const addTorrent = new URLSearchParams(globalThis.location.search).get(
@@ -550,81 +606,121 @@ export class Transmission extends EventTarget {
/// SELECTION
_getSelectedRows() {
return this._rows.filter((r) => r.isSelected());
}
getSelectedTorrents() {
return this._getSelectedRows().map((r) => r.getTorrent());
return [...this._selectedTorrentIds]
.map((id) => this._torrents[id])
.filter(Boolean);
}
_getSelectedTorrentIds() {
return Transmission._getTorrentIds(this.getSelectedTorrents());
return [...this._selectedTorrentIds];
}
_setSelectedRow(row) {
const e_sel = row ? row.getElement() : null;
for (const e of this.elements.torrent_list.children) {
e.classList.toggle('selected', e === e_sel);
_setSelectedTorrent(torrentId) {
this._selectedTorrentIds.clear();
if (torrentId) {
this._selectedTorrentIds.add(torrentId);
}
this._updateVisibleSelections();
this._dispatchSelectionChanged();
}
_selectRow(row) {
row.getElement().classList.add('selected');
_selectTorrent(torrentId) {
this._selectedTorrentIds.add(torrentId);
this._updateVisibleSelections();
this._dispatchSelectionChanged();
}
_deselectRow(row) {
row.getElement().classList.remove('selected');
_deselectTorrent(torrentId) {
this._selectedTorrentIds.delete(torrentId);
this._updateVisibleSelections();
this._dispatchSelectionChanged();
}
_selectAll() {
for (const e of this.elements.torrent_list.children) {
e.classList.add('selected');
for (const torrent of this._torrentOrder) {
this._selectedTorrentIds.add(torrent.getId());
}
this._updateVisibleSelections();
this._dispatchSelectionChanged();
}
_deselectAll() {
for (const e of this.elements.torrent_list.children) {
e.classList.remove('selected');
}
this._selectedTorrentIds.clear();
this._updateVisibleSelections();
this._dispatchSelectionChanged();
delete this._last_torrent_clicked;
}
_updateVisibleSelections() {
// Update selection classes on visible DOM elements
if (this.elements.torrent_list) {
for (const element of this.elements.torrent_list.children) {
const torrentId = Number.parseInt(element.dataset.torrentId, 10);
if (torrentId) {
element.classList.toggle(
'selected',
this._selectedTorrentIds.has(torrentId),
);
}
}
}
}
_indexOfLastTorrent() {
return this._rows.findIndex(
(row) => row.getTorrentId() === this._last_torrent_clicked,
if (!this._last_torrent_clicked) {
return -1;
}
return this._torrentOrder.findIndex(
(torrent) => torrent.getId() === this._last_torrent_clicked,
);
}
// Select a range from this row to the last clicked torrent
_selectRange(row) {
const last = this._indexOfLastTorrent();
// Convert row to torrent ID and use new implementation
if (row && row.getTorrent) {
this._selectRangeToTorrent(row.getTorrent().getId());
}
}
if (last === -1) {
this._selectRow(row);
} else {
// select the range between the previous & current
const next = this._rows.indexOf(row);
const min = Math.min(last, next);
const max = Math.max(last, next);
for (let index = min; index <= max; ++index) {
this._selectRow(this._rows[index]);
}
// Select a range from the given torrent ID to the last clicked torrent
_selectRangeToTorrent(torrentId) {
if (!this._last_torrent_clicked) {
this._selectTorrent(torrentId);
return;
}
this._dispatchSelectionChanged();
// Find indices in the current torrent order
const currentIndex = this._torrentOrder.findIndex(
(t) => t.getId() === torrentId,
);
const lastIndex = this._torrentOrder.findIndex(
(t) => t.getId() === this._last_torrent_clicked,
);
if (currentIndex === -1 || lastIndex === -1) {
this._selectTorrent(torrentId);
return;
}
// Select the range between the previous & current
const min = Math.min(lastIndex, currentIndex);
const max = Math.max(lastIndex, currentIndex);
for (let index = min; index <= max; ++index) {
this._selectTorrent(this._torrentOrder[index].getId());
}
}
_dispatchSelectionChanged() {
const nonselected = [];
const selected = [];
for (const r of this._rows) {
(r.isSelected() ? selected : nonselected).push(r.getTorrent());
for (const torrent of Object.values(this._torrents)) {
if (this._selectedTorrentIds.has(torrent.getId())) {
selected.push(torrent);
} else {
nonselected.push(torrent);
}
}
const event = new Event('torrent-selection-changed');
@@ -681,7 +777,7 @@ export class Transmission extends EventTarget {
}
const any_popup_active = document.querySelector('.popup:not(.hidden)');
const rows = this._rows;
const torrents = this._torrentOrder;
// Some shortcuts can only be used if the following conditions are met:
// 1. when no input fields are focused
@@ -691,11 +787,11 @@ export class Transmission extends EventTarget {
const shift_key = keyCode === 16; // shift key pressed
const up_key = keyCode === 38; // up key pressed
const dn_key = keyCode === 40; // down key pressed
if ((up_key || dn_key) && rows.length > 0) {
if ((up_key || dn_key) && torrents.length > 0) {
const last = this._indexOfLastTorrent();
const anchor = this._shift_index;
const min = 0;
const max = rows.length - 1;
const max = torrents.length - 1;
let index = last;
if (dn_key && index + 1 <= max) {
@@ -704,7 +800,7 @@ export class Transmission extends EventTarget {
--index;
}
const r = rows[index];
const torrent = torrents[index];
if (anchor >= 0) {
// user is extending the selection
@@ -713,24 +809,34 @@ export class Transmission extends EventTarget {
(anchor <= last && last < index) ||
(anchor >= last && last > index)
) {
this._selectRow(r);
this._selectTorrent(torrent.getId());
} else if (
(anchor >= last && index > last) ||
(anchor <= last && last > index)
) {
this._deselectRow(rows[last]);
this._deselectTorrent(torrents[last].getId());
}
} else {
if (shiftKey) {
this._selectRange(r);
this._selectRangeToTorrent(torrent.getId());
} else {
this._setSelectedRow(r);
this._setSelectedTorrent(torrent.getId());
}
}
if (r) {
this._last_torrent_clicked = r.getTorrentId();
r.getElement().scrollIntoView();
if (torrent) {
event_.preventDefault();
this._last_torrent_clicked = torrent.getId();
const rowElem = [...this.elements.torrent_list.children].find(
(element) =>
Number.parseInt(element.dataset.torrentId, 10) ===
torrent.getId(),
);
if (rowElem) {
rowElem.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
}
} else if (shift_key) {
this._shift_index = this._indexOfLastTorrent();
@@ -833,18 +939,14 @@ export class Transmission extends EventTarget {
);
}
_onTorrentChanged(event_) {
_onTorrentChanged() {
if (this.changeStatus) {
this._dispatchSelectionChanged();
this.changeStatus = false;
}
// update our dirty fields
const tor = event_.currentTarget;
this.dirtyTorrents.add(tor.getId());
// enqueue ui refreshes
this.refilterSoon();
this.refilterAllSoon();
}
updateTorrents(ids, fields) {
@@ -868,7 +970,6 @@ export class Transmission extends EventTarget {
} else {
t = this._torrents[id] = new Torrent(o);
t.addEventListener('dataChanged', this._onTorrentChanged.bind(this));
this.dirtyTorrents.add(id);
// do we need more info for this torrent?
if (!('name' in t.fields) || !('status' in t.fields)) {
needinfo.push(id);
@@ -884,12 +985,12 @@ export class Transmission extends EventTarget {
...Torrent.Fields.Stats,
];
this.updateTorrents(needinfo, more_fields);
this.refilterSoon();
this.refilterAllSoon();
}
if (removed_ids) {
this._deleteTorrents(removed_ids);
this.refilterSoon();
this.refilterAllSoon();
}
});
}
@@ -924,8 +1025,24 @@ TODO: fix this when notifications get fixed
}
_onRowClicked(event_) {
const meta_key = event_.metaKey || event_.ctrlKey,
{ row } = event_.currentTarget;
// Find the torrent row element
let rowElement = event_.target;
while (rowElement && !rowElement.classList.contains('torrent')) {
rowElement = rowElement.parentNode;
}
if (!rowElement || !rowElement.dataset.torrentId) {
return;
}
const torrentId = Number.parseInt(rowElement.dataset.torrentId, 10);
const torrent = this._torrents[torrentId];
if (!torrent) {
return;
}
const meta_key = event_.metaKey || event_.ctrlKey;
const isSelected = this._selectedTorrentIds.has(torrentId);
if (this.popup[Transmission.default_popup_level]) {
this.setCurrentPopup(null);
@@ -936,37 +1053,36 @@ TODO: fix this when notifications get fixed
event_.stopPropagation();
if (event_.shiftKey) {
this._selectRange(row);
this._selectRangeToTorrent(torrentId);
// Need to deselect any selected text
globalThis.focus();
// Apple-Click, not selected
} else if (!row.isSelected() && meta_key) {
this._selectRow(row);
} else if (!isSelected && meta_key) {
this._selectTorrent(torrentId);
// Regular Click, not selected
} else if (!row.isSelected()) {
this._setSelectedRow(row);
} else if (!isSelected) {
this._setSelectedTorrent(torrentId);
// Apple-Click, selected
} else if (row.isSelected() && meta_key) {
this._deselectRow(row);
} else if (isSelected && meta_key) {
this._deselectTorrent(torrentId);
// Regular Click, selected
} else if (row.isSelected()) {
this._setSelectedRow(row);
} else if (isSelected) {
this._setSelectedTorrent(torrentId);
}
this._last_torrent_clicked = row.getTorrentId();
this._last_torrent_clicked = torrentId;
}
_deleteTorrents(ids) {
if (ids && ids.length > 0) {
for (const id of ids) {
this.dirtyTorrents.add(id);
delete this._torrents[id];
}
this.refilterSoon();
this.refilterAllSoon();
}
}
@@ -1079,7 +1195,11 @@ TODO: fix this when notifications get fixed
(accumulator, tor) => accumulator + tor.getDownloadSpeed(),
0,
);
const string = fmt.countString('Transfer', 'Transfers', this._rows.length);
const string = fmt.countString(
'Transfer',
'Transfers',
this._torrentOrder.length,
);
setTextContent(this.speed.down, fmt.speedBps(d));
setTextContent(this.speed.up, fmt.speedBps(u));
@@ -1140,11 +1260,9 @@ TODO: fix this when notifications get fixed
}
}
_refilter(rebuildEverything) {
_refilter() {
const { sort_mode, sort_direction, filter_mode } = this.prefs;
const filter_tracker = this.filterTracker;
const renderer = this.torrentRenderer;
const list = this.elements.torrent_list;
let filter_text = null;
let labels = null;
@@ -1160,125 +1278,51 @@ TODO: fix this when notifications get fixed
labels = [];
}
const countRows = () => [...list.children].length;
const countSelectedRows = () =>
[...list.children].reduce(
(n, e) => (n + e.classList.contains('selected') ? 1 : 0),
0,
);
const old_row_count = countRows();
const old_sel_count = countSelectedRows();
this._updateFilterSelect();
if (rebuildEverything) {
while (list.firstChild) {
list.firstChild.remove();
}
this._rows = [];
this.dirtyTorrents = new Set(Object.keys(this._torrents));
document.querySelector('#reset').style.display =
this.filterText.length > 0 ? 'block' : 'none';
}
// rows that overlap with dirtyTorrents need to be refiltered.
// those that don't are 'clean' and don't need refiltering.
const clean_rows = [];
let dirty_rows = [];
for (const row of this._rows) {
if (this.dirtyTorrents.has(row.getTorrentId())) {
dirty_rows.push(row);
} else {
clean_rows.push(row);
// Get filtered and sorted torrents
const filteredTorrents = [];
for (const torrent of Object.values(this._torrents)) {
if (torrent.test(filter_mode, filter_tracker, filter_text, labels)) {
filteredTorrents.push(torrent);
}
}
// remove the dirty rows from the dom
for (const row of dirty_rows) {
row.getElement().remove();
// Sort the torrents
filteredTorrents.sort((a, b) =>
Torrent.compareTorrents(a, b, sort_mode, sort_direction),
);
// Update torrent order for range selection
this._torrentOrder = filteredTorrents;
// Generate HTML for each torrent
const rowsHTML = filteredTorrents.map((torrent) =>
this._generateTorrentRowHTML(torrent),
);
// Update clusterize with new data
if (rowsHTML.length === 0) {
this._clusterize.update(['<li></li>']);
} else {
this._clusterize.update(rowsHTML);
}
// drop any dirty rows that don't pass the filter test
const temporary = [];
for (const row of dirty_rows) {
const id = row.getTorrentId();
const t = this._torrents[id];
if (t && t.test(filter_mode, filter_tracker, filter_text, labels)) {
temporary.push(row);
}
this.dirtyTorrents.delete(id);
}
dirty_rows = temporary;
// make new rows for dirty torrents that pass the filter test
// but don't already have a row
for (const id of this.dirtyTorrents.values()) {
const t = this._torrents[id];
if (t && t.test(filter_mode, filter_tracker, filter_text, labels)) {
const row = new TorrentRow(renderer, this, t);
const e = row.getElement();
e.row = row;
dirty_rows.push(row);
e.addEventListener('click', this._onRowClicked.bind(this));
}
// Refresh clusterize if virtual scrolling is being performed
// Clusterize kicks in above 25 blocks * 4 clusters
if (rowsHTML.length > 100) {
setTimeout(() => {
this._clusterize.refresh(true);
}, 50);
}
// sort the dirty rows
this.sortRows(dirty_rows);
// now we have two sorted arrays of rows
// and can do a simple two-way sorted merge.
const rows = [];
const cmax = clean_rows.length;
const dmax = dirty_rows.length;
const frag = document.createDocumentFragment();
let ci = 0;
let di = 0;
while (ci !== cmax || di !== dmax) {
let push_clean = null;
if (ci === cmax) {
push_clean = false;
} else if (di === dmax) {
push_clean = true;
} else {
const c = Torrent.compareTorrents(
clean_rows[ci].getTorrent(),
dirty_rows[di].getTorrent(),
sort_mode,
sort_direction,
);
push_clean = c < 0;
}
if (push_clean) {
rows.push(clean_rows[ci++]);
} else {
const row = dirty_rows[di++];
const e = row.getElement();
if (ci === cmax) {
frag.append(e);
} else {
list.insertBefore(e, clean_rows[ci].getElement());
}
rows.push(row);
}
}
list.append(frag);
// update our implementation fields
this._rows = rows;
this.dirtyTorrents.clear();
// Update status bar
this._updateStatusbar();
if (
old_sel_count !== countSelectedRows() ||
old_row_count !== countRows()
) {
this._dispatchSelectionChanged();
}
// Update visible selections after clusterize renders
setTimeout(() => {
this._updateVisibleSelections();
}, 0);
}
setFilterTracker(sitename) {