WebUI: Optimize table performance with virtual list

Adding virtual list support to dynamic tables to improve performance on large lists, I observed a 100x performance improvement on rendering on a torrent table with 5000 torrents.
This optimization is disabled by default and can be enabled in options.

PR #22502.
This commit is contained in:
tehcneko
2025-04-20 17:18:26 +08:00
committed by GitHub
parent 250fef4ee7
commit b4a16f6464
9 changed files with 474 additions and 359 deletions

View File

@@ -33,16 +33,11 @@ window.qBittorrent.PropFiles ??= (() => {
const exports = () => {
return {
normalizePriority: normalizePriority,
isDownloadCheckboxExists: isDownloadCheckboxExists,
createDownloadCheckbox: createDownloadCheckbox,
updateDownloadCheckbox: updateDownloadCheckbox,
isPriorityComboExists: isPriorityComboExists,
createPriorityCombo: createPriorityCombo,
updatePriorityCombo: updatePriorityCombo,
updateData: updateData,
collapseIconClicked: collapseIconClicked,
expandFolder: expandFolder,
collapseFolder: collapseFolder,
clear: clear
};
};
@@ -126,25 +121,20 @@ window.qBittorrent.PropFiles ??= (() => {
updateGlobalCheckbox();
};
const isDownloadCheckboxExists = (id) => {
return $(`cbPrio${id}`) !== null;
};
const createDownloadCheckbox = (id, fileId, checked) => {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = `cbPrio${id}`;
checkbox.setAttribute("data-id", id);
checkbox.setAttribute("data-file-id", fileId);
checkbox.className = "DownloadedCB";
checkbox.addEventListener("click", fileCheckboxClicked);
updateCheckbox(checkbox, checked);
return checkbox;
};
const updateDownloadCheckbox = (id, checked) => {
const checkbox = $(`cbPrio${id}`);
const updateDownloadCheckbox = (checkbox, id, fileId, checked) => {
checkbox.setAttribute("data-id", id);
checkbox.setAttribute("data-file-id", fileId);
updateCheckbox(checkbox, checked);
};
@@ -162,10 +152,6 @@ window.qBittorrent.PropFiles ??= (() => {
}
};
const isPriorityComboExists = (id) => {
return $(`comboPrio${id}`) !== null;
};
const createPriorityCombo = (id, fileId, selectedPriority) => {
const createOption = (priority, isSelected, text) => {
const option = document.createElement("option");
@@ -195,8 +181,10 @@ window.qBittorrent.PropFiles ??= (() => {
return select;
};
const updatePriorityCombo = (id, selectedPriority) => {
const combobox = $(`comboPrio${id}`);
const updatePriorityCombo = (combobox, id, fileId, selectedPriority) => {
combobox.id = `comboPrio${id}`;
combobox.setAttribute("data-id", id);
combobox.setAttribute("data-file-id", fileId);
if (Number(combobox.value) !== selectedPriority)
selectComboboxPriority(combobox, selectedPriority);
};
@@ -258,9 +246,9 @@ window.qBittorrent.PropFiles ??= (() => {
const updateGlobalCheckbox = () => {
const checkbox = $("tristate_cb");
if (isAllCheckboxesChecked())
if (torrentFilesTable.isAllCheckboxesChecked())
setCheckboxChecked(checkbox);
else if (isAllCheckboxesUnchecked())
else if (torrentFilesTable.isAllCheckboxesUnchecked())
setCheckboxUnchecked(checkbox);
else
setCheckboxPartial(checkbox);
@@ -283,10 +271,6 @@ window.qBittorrent.PropFiles ??= (() => {
checkbox.indeterminate = true;
};
const isAllCheckboxesChecked = () => Array.prototype.every.call(document.querySelectorAll("input.DownloadedCB"), (checkbox => checkbox.checked));
const isAllCheckboxesUnchecked = () => Array.prototype.every.call(document.querySelectorAll("input.DownloadedCB"), (checkbox => !checkbox.checked));
const setFilePriority = (ids, fileIds, priority) => {
if (current_hash === "")
return;
@@ -317,8 +301,6 @@ window.qBittorrent.PropFiles ??= (() => {
if (combobox !== null)
selectComboboxPriority(combobox, priority);
});
torrentFilesTable.updateTable(false);
};
let loadTorrentFilesDataTimer = -1;
@@ -364,7 +346,7 @@ window.qBittorrent.PropFiles ??= (() => {
else {
handleNewTorrentFiles(files);
if (loadedNewTorrent)
collapseAllNodes();
torrentFilesTable.collapseAllNodes();
}
})
.finally(() => {
@@ -470,29 +452,6 @@ window.qBittorrent.PropFiles ??= (() => {
torrentFilesTable.reselectRows(selectedFiles);
};
const collapseIconClicked = (event) => {
const id = event.getAttribute("data-id");
const node = torrentFilesTable.getNode(id);
const isCollapsed = (event.parentElement.getAttribute("data-collapsed") === "true");
if (isCollapsed)
expandNode(node);
else
collapseNode(node);
};
const expandFolder = (id) => {
const node = torrentFilesTable.getNode(id);
if (node.isFolder)
expandNode(node);
};
const collapseFolder = (id) => {
const node = torrentFilesTable.getNode(id);
if (node.isFolder)
collapseNode(node);
};
const filesPriorityMenuClicked = (priority) => {
const selectedRows = torrentFilesTable.selectedRowsIds();
if (selectedRows.length === 0)
@@ -501,9 +460,8 @@ window.qBittorrent.PropFiles ??= (() => {
const rowIds = [];
const fileIds = [];
selectedRows.forEach((rowId) => {
const elem = $(`comboPrio${rowId}`);
rowIds.push(rowId);
fileIds.push(elem.getAttribute("data-file-id"));
fileIds.push(torrentFilesTable.getRowFileId(rowId));
});
const uniqueRowIds = {};
@@ -607,7 +565,7 @@ window.qBittorrent.PropFiles ??= (() => {
}
});
torrentFilesTable.setup("torrentFilesTableDiv", "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu);
torrentFilesTable.setup("torrentFilesTableDiv", "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu, true);
// inject checkbox into table header
const tableHeaders = document.querySelectorAll("#torrentFilesTableFixedHeaderDiv .dynamicTableHeader th");
if (tableHeaders.length > 0) {
@@ -641,111 +599,12 @@ window.qBittorrent.PropFiles ??= (() => {
torrentFilesTable.updateTable();
if (value.trim() === "")
collapseAllNodes();
torrentFilesTable.collapseAllNodes();
else
expandAllNodes();
torrentFilesTable.expandAllNodes();
}, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
});
/**
* Show/hide a node's row
*/
const _hideNode = (node, shouldHide) => {
const span = $(`filesTablefileName${node.rowId}`);
// span won't exist if row has been filtered out
if (span === null)
return;
const rowElem = span.parentElement.parentElement;
rowElem.classList.toggle("invisible", shouldHide);
};
/**
* Update a node's collapsed state and icon
*/
const _updateNodeState = (node, isCollapsed) => {
const span = $(`filesTablefileName${node.rowId}`);
// span won't exist if row has been filtered out
if (span === null)
return;
const td = span.parentElement;
// store collapsed state
td.setAttribute("data-collapsed", isCollapsed);
// rotate the collapse icon
const collapseIcon = td.getElementsByClassName("filesTableCollapseIcon")[0];
collapseIcon.classList.toggle("rotate", isCollapsed);
};
const _isCollapsed = (node) => {
const span = $(`filesTablefileName${node.rowId}`);
if (span === null)
return true;
const td = span.parentElement;
return td.getAttribute("data-collapsed") === "true";
};
const expandNode = (node) => {
_collapseNode(node, false, false, false);
};
const collapseNode = (node) => {
_collapseNode(node, true, false, false);
};
const expandAllNodes = () => {
const root = torrentFilesTable.getRoot();
root.children.each((node) => {
node.children.each((child) => {
_collapseNode(child, false, true, false);
});
});
};
const collapseAllNodes = () => {
const root = torrentFilesTable.getRoot();
root.children.each((node) => {
node.children.each((child) => {
_collapseNode(child, true, true, false);
});
});
};
/**
* Collapses a folder node with the option to recursively collapse all children
* @param {FolderNode} node the node to collapse/expand
* @param {boolean} shouldCollapse true if the node should be collapsed, false if it should be expanded
* @param {boolean} applyToChildren true if the node's children should also be collapsed, recursively
* @param {boolean} isChildNode true if the current node is a child of the original node we collapsed/expanded
*/
const _collapseNode = (node, shouldCollapse, applyToChildren, isChildNode) => {
if (!node.isFolder)
return;
const shouldExpand = !shouldCollapse;
const isNodeCollapsed = _isCollapsed(node);
const nodeInCorrectState = ((shouldCollapse && isNodeCollapsed) || (shouldExpand && !isNodeCollapsed));
const canSkipNode = (isChildNode && (!applyToChildren || nodeInCorrectState));
if (!isChildNode || applyToChildren || !canSkipNode)
_updateNodeState(node, shouldCollapse);
node.children.each((child) => {
_hideNode(child, shouldCollapse);
if (!child.isFolder)
return;
// don't expand children that have been independently collapsed, unless applyToChildren is true
const shouldExpandChildren = (shouldExpand && applyToChildren);
const isChildCollapsed = _isCollapsed(child);
if (!shouldExpandChildren && isChildCollapsed)
return;
_collapseNode(child, shouldCollapse, applyToChildren, true);
});
};
const clear = () => {
torrentFilesTable.clear();
};