perf: kill lag with 1000s of rows during upload
The two worst hot paths were:
- clicking a row triggered a full table rebuild with sort+innerHTML
(queue AND recent panel), and the opposite panel got cleared with
another full rebuild
- every upload progress tick (4/sec) scanned queueJobs twice and
filtered sessionFilesData twice just to update the status bar
Fixes:
- applyQueueSelectionClasses / applyRecentSelectionClasses toggle the
.selected class on existing rows instead of rebuilding the tbody.
Click selection is now O(rendered rows) instead of O(total × sort).
- maybeAddSessionFile schedules renderRecentUploadsPanel via rAF so
a batch of 1000 successful uploads coalesces into one render.
- sortRecentFiles memoizes its result per (sortKey, direction, len)
— unchanged sort state + unchanged length returns the cached array
instead of re-sorting thousands of entries.
- _computeQueueStats now also returns inProgressBytes, dropping the
second queueJobs scan in updateStatusBar.
- session done/error counts are maintained incrementally, replacing
two sessionFilesData.filter().length calls every status-bar tick.
- handleRowClick uses the _jobIndexById map instead of Array.find.
This commit is contained in:
parent
7dc68c7615
commit
85287aa620
105
renderer/app.js
105
renderer/app.js
@ -57,6 +57,9 @@ let historySortState = { key: 'date', direction: 'desc' };
|
|||||||
let sessionFilesData = [];
|
let sessionFilesData = [];
|
||||||
const recentSortState = { key: 'date', direction: 'desc' };
|
const recentSortState = { key: 'date', direction: 'desc' };
|
||||||
const selectedRecentIds = new Set();
|
const selectedRecentIds = new Set();
|
||||||
|
// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar.
|
||||||
|
let _sessionDoneCount = 0;
|
||||||
|
let _sessionErrorCount = 0;
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
async function init() {
|
async function init() {
|
||||||
@ -787,6 +790,34 @@ function scheduleQueueRender() {
|
|||||||
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
|
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _recentRenderQueued = false;
|
||||||
|
function scheduleRecentRender() {
|
||||||
|
if (_recentRenderQueued) return;
|
||||||
|
_recentRenderQueued = true;
|
||||||
|
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the .selected class on existing rows without rebuilding the table.
|
||||||
|
// Used on click/selection changes — O(rendered rows) instead of O(total rows × sort).
|
||||||
|
function applyQueueSelectionClasses() {
|
||||||
|
const tbody = document.getElementById('queueBody');
|
||||||
|
if (!tbody) return;
|
||||||
|
const rows = tbody.querySelectorAll('.queue-row');
|
||||||
|
for (const tr of rows) {
|
||||||
|
tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRecentSelectionClasses() {
|
||||||
|
const tbody = document.getElementById('recentFilesBody');
|
||||||
|
if (!tbody) return;
|
||||||
|
const rows = tbody.querySelectorAll('.recent-file-row');
|
||||||
|
for (const tr of rows) {
|
||||||
|
const order = parseInt(tr.dataset.order, 10);
|
||||||
|
tr.classList.toggle('selected', selectedRecentIds.has(order));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleThrottledUIUpdate() {
|
function scheduleThrottledUIUpdate() {
|
||||||
if (_uiUpdateTimer) return;
|
if (_uiUpdateTimer) return;
|
||||||
_uiUpdateTimer = setTimeout(() => {
|
_uiUpdateTimer = setTimeout(() => {
|
||||||
@ -1007,8 +1038,8 @@ function getStatusText(job) {
|
|||||||
// --- Queue interactions ---
|
// --- Queue interactions ---
|
||||||
function handleRowClick(e, row) {
|
function handleRowClick(e, row) {
|
||||||
const jobId = row.dataset.jobId;
|
const jobId = row.dataset.jobId;
|
||||||
// Clear recent panel selection when clicking in queue
|
// Clear recent panel selection when clicking in queue — class-toggle only.
|
||||||
if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); renderRecentUploadsPanel(); }
|
if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); applyRecentSelectionClasses(); }
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId);
|
if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId);
|
||||||
@ -1027,7 +1058,7 @@ function handleRowClick(e, row) {
|
|||||||
selectedJobIds.clear();
|
selectedJobIds.clear();
|
||||||
selectedJobIds.add(jobId);
|
selectedJobIds.add(jobId);
|
||||||
// Single click on done job -> copy link
|
// Single click on done job -> copy link
|
||||||
const job = queueJobs.find(j => j.id === jobId);
|
const job = _jobIndexById.get(jobId);
|
||||||
if (job && job.status === 'done' && job.result) {
|
if (job && job.status === 'done' && job.result) {
|
||||||
const link = job.result.download_url || job.result.embed_url || '';
|
const link = job.result.download_url || job.result.embed_url || '';
|
||||||
if (link) {
|
if (link) {
|
||||||
@ -1036,7 +1067,9 @@ function handleRowClick(e, row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderQueueTable();
|
// Selection changes don't change sort order / row content — just toggle classes.
|
||||||
|
applyQueueSelectionClasses();
|
||||||
|
updateQueueActionButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Context menu ---
|
// --- Context menu ---
|
||||||
@ -1048,7 +1081,8 @@ function handleRowContextMenu(e, row) {
|
|||||||
if (!selectedJobIds.has(jobId)) {
|
if (!selectedJobIds.has(jobId)) {
|
||||||
selectedJobIds.clear();
|
selectedJobIds.clear();
|
||||||
selectedJobIds.add(jobId);
|
selectedJobIds.add(jobId);
|
||||||
renderQueueTable();
|
applyQueueSelectionClasses();
|
||||||
|
updateQueueActionButtons();
|
||||||
}
|
}
|
||||||
showContextMenu(e.clientX, e.clientY);
|
showContextMenu(e.clientX, e.clientY);
|
||||||
}
|
}
|
||||||
@ -1112,7 +1146,14 @@ function hideContextMenu() {
|
|||||||
|
|
||||||
function deleteSelectedRecentFiles() {
|
function deleteSelectedRecentFiles() {
|
||||||
if (selectedRecentIds.size === 0) return;
|
if (selectedRecentIds.size === 0) return;
|
||||||
sessionFilesData = sessionFilesData.filter(r => !selectedRecentIds.has(r.order));
|
let removedDone = 0, removedErr = 0;
|
||||||
|
sessionFilesData = sessionFilesData.filter(r => {
|
||||||
|
if (!selectedRecentIds.has(r.order)) return true;
|
||||||
|
if (r.isError) removedErr++; else removedDone++;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
_sessionDoneCount = Math.max(0, _sessionDoneCount - removedDone);
|
||||||
|
_sessionErrorCount = Math.max(0, _sessionErrorCount - removedErr);
|
||||||
selectedRecentIds.clear();
|
selectedRecentIds.clear();
|
||||||
renderRecentUploadsPanel();
|
renderRecentUploadsPanel();
|
||||||
}
|
}
|
||||||
@ -1121,6 +1162,8 @@ function clearAllRecentFiles() {
|
|||||||
if (sessionFilesData.length === 0) return;
|
if (sessionFilesData.length === 0) return;
|
||||||
if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return;
|
if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return;
|
||||||
sessionFilesData = [];
|
sessionFilesData = [];
|
||||||
|
_sessionDoneCount = 0;
|
||||||
|
_sessionErrorCount = 0;
|
||||||
selectedRecentIds.clear();
|
selectedRecentIds.clear();
|
||||||
renderRecentUploadsPanel();
|
renderRecentUploadsPanel();
|
||||||
}
|
}
|
||||||
@ -1887,7 +1930,9 @@ function maybeAddSessionFile(job) {
|
|||||||
isError: false,
|
isError: false,
|
||||||
order: sessionFilesData.length
|
order: sessionFilesData.length
|
||||||
});
|
});
|
||||||
renderRecentUploadsPanel();
|
_sessionDoneCount++;
|
||||||
|
// Coalesce rapid successive adds into one render per frame.
|
||||||
|
scheduleRecentRender();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1921,10 +1966,11 @@ function applySummaryResults(summary) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-pass queue stats computation (shared by status bar + stats panel)
|
// Single-pass queue stats computation (shared by status bar + stats panel).
|
||||||
|
// Also tracks inProgressBytes so the status bar doesn't need a second scan.
|
||||||
function _computeQueueStats() {
|
function _computeQueueStats() {
|
||||||
let remaining = 0, inProgress = 0, done = 0, errors = 0;
|
let remaining = 0, inProgress = 0, done = 0, errors = 0;
|
||||||
let bytesRemaining = 0, totalSize = 0, remainingSize = 0;
|
let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0;
|
||||||
const total = queueJobs.length;
|
const total = queueJobs.length;
|
||||||
|
|
||||||
for (let i = 0; i < total; i++) {
|
for (let i = 0; i < total; i++) {
|
||||||
@ -1937,6 +1983,7 @@ function _computeQueueStats() {
|
|||||||
if (s === 'uploading' || s === 'getting-server' || s === 'retrying') {
|
if (s === 'uploading' || s === 'getting-server' || s === 'retrying') {
|
||||||
inProgress++;
|
inProgress++;
|
||||||
remaining++;
|
remaining++;
|
||||||
|
inProgressBytes += bu;
|
||||||
bytesRemaining += Math.max(0, bt - bu);
|
bytesRemaining += Math.max(0, bt - bu);
|
||||||
remainingSize += Math.max(0, bt - bu);
|
remainingSize += Math.max(0, bt - bu);
|
||||||
} else if (s === 'preview' || s === 'queued') {
|
} else if (s === 'preview' || s === 'queued') {
|
||||||
@ -1952,7 +1999,7 @@ function _computeQueueStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize };
|
return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatusBar() {
|
function updateStatusBar() {
|
||||||
@ -1972,15 +2019,7 @@ function updateStatusBar() {
|
|||||||
|
|
||||||
document.getElementById('sbState').textContent = stateText;
|
document.getElementById('sbState').textContent = stateText;
|
||||||
document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0);
|
document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0);
|
||||||
// Session-based bytes: survive removeFromQueueOnDone
|
const uploadedSize = _sessionUploadedBytes + stats.inProgressBytes;
|
||||||
// Uploaded = done jobs (session) + in-progress bytes still in queue
|
|
||||||
let inProgressBytes = 0;
|
|
||||||
for (const job of queueJobs) {
|
|
||||||
if (job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying') {
|
|
||||||
inProgressBytes += job.bytesUploaded || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const uploadedSize = _sessionUploadedBytes + inProgressBytes;
|
|
||||||
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
|
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
|
||||||
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
|
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
|
||||||
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
|
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
|
||||||
@ -1988,10 +2027,8 @@ function updateStatusBar() {
|
|||||||
document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`;
|
document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`;
|
||||||
document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`;
|
document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`;
|
||||||
document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`;
|
document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`;
|
||||||
const sessionDone = sessionFilesData.filter(r => !r.isError).length;
|
document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`;
|
||||||
const sessionErrors = sessionFilesData.filter(r => r.isError).length;
|
document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`;
|
||||||
document.getElementById('sbDoneCount').textContent = `Done: ${sessionDone}`;
|
|
||||||
document.getElementById('sbErrorCount').textContent = `Error: ${sessionErrors}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Health Check ---
|
// --- Health Check ---
|
||||||
@ -3052,9 +3089,17 @@ async function exportHistory() {
|
|||||||
showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`);
|
showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memoize sort result: invalidated only when data length changes or sort state changes.
|
||||||
|
// Selection changes and re-renders reuse the cached sorted array — a big win when
|
||||||
|
// the panel has thousands of rows and the sort is stable.
|
||||||
|
let _recentSortCache = { sig: '', result: [] };
|
||||||
|
|
||||||
function sortRecentFiles(data) {
|
function sortRecentFiles(data) {
|
||||||
const sorted = data.slice();
|
|
||||||
const { key, direction } = recentSortState;
|
const { key, direction } = recentSortState;
|
||||||
|
const sig = `${key}|${direction}|${data.length}`;
|
||||||
|
if (_recentSortCache.sig === sig) return _recentSortCache.result;
|
||||||
|
|
||||||
|
const sorted = data.slice();
|
||||||
const dir = direction === 'asc' ? 1 : -1;
|
const dir = direction === 'asc' ? 1 : -1;
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order));
|
if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order));
|
||||||
@ -3063,6 +3108,7 @@ function sortRecentFiles(data) {
|
|||||||
if (key === 'link') return dir * _collatorDE.compare(a.link, b.link);
|
if (key === 'link') return dir * _collatorDE.compare(a.link, b.link);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
_recentSortCache = { sig, result: sorted };
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3106,14 +3152,16 @@ function renderRecentUploadsPanel() {
|
|||||||
tbody.addEventListener('click', (e) => {
|
tbody.addEventListener('click', (e) => {
|
||||||
const tr = e.target.closest('.recent-file-row');
|
const tr = e.target.closest('.recent-file-row');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
// Clear queue selection when clicking in recent panel
|
// Clear queue selection when clicking in recent panel — class-toggle only.
|
||||||
if (selectedJobIds.size > 0) { selectedJobIds.clear(); renderQueueTable(); updateQueueActionButtons(); }
|
if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); updateQueueActionButtons(); }
|
||||||
const id = parseInt(tr.dataset.order, 10);
|
const id = parseInt(tr.dataset.order, 10);
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id);
|
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id);
|
||||||
else selectedRecentIds.add(id);
|
else selectedRecentIds.add(id);
|
||||||
} else if (e.shiftKey && selectedRecentIds.size > 0) {
|
} else if (e.shiftKey && selectedRecentIds.size > 0) {
|
||||||
const sortedOrders = sortRecentFiles(sessionFilesData).map(r => r.order);
|
// Use already-sorted DOM order (cheap) instead of re-sorting the full array.
|
||||||
|
const sortedOrders = Array.from(tbody.querySelectorAll('.recent-file-row'))
|
||||||
|
.map(r => parseInt(r.dataset.order, 10));
|
||||||
const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o));
|
const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o));
|
||||||
const curIdx = sortedOrders.indexOf(id);
|
const curIdx = sortedOrders.indexOf(id);
|
||||||
if (lastIdx >= 0 && curIdx >= 0) {
|
if (lastIdx >= 0 && curIdx >= 0) {
|
||||||
@ -3125,7 +3173,8 @@ function renderRecentUploadsPanel() {
|
|||||||
selectedRecentIds.clear();
|
selectedRecentIds.clear();
|
||||||
selectedRecentIds.add(id);
|
selectedRecentIds.add(id);
|
||||||
}
|
}
|
||||||
renderRecentUploadsPanel();
|
// Selection change — toggle classes, no tbody rebuild.
|
||||||
|
applyRecentSelectionClasses();
|
||||||
});
|
});
|
||||||
|
|
||||||
tbody.addEventListener('dblclick', (e) => {
|
tbody.addEventListener('dblclick', (e) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user