perf: tab switch O(1), parallel settings save, cached hoster counts, sort-cache reuse

Four user-visible lag sources tracked down from a wider audit:

  - Tab click was running three full querySelectorAll walks per click
    (remove active from all tabs, all views, find new tab). Replaced
    with delegated listener on the tab bar plus cached node maps;
    tab switching is now O(1) and a no-op when clicking the active tab.

  - saveSettings awaited saveHosterSettings + saveGlobalSettings
    serially and then re-fetched the full config from main. With
    autosave firing on every keystroke this added 100–200ms of IPC
    stall per input change. The two saves now run in parallel and the
    post-save getConfig refetch is gone — we know the new state.

  - showContextMenu rebuilt hosterCounts (queueJobs.forEach) on every
    right-click. Replaced with a length-keyed cache; right-click on a
    5000-job queue no longer pauses while counting.

  - Recent-panel shift-click was querying every .recent-file-row in
    the DOM and re-parsing data-order. Reuses _recentSortCache.result
    instead, O(visible) vs O(N).
This commit is contained in:
Administrator 2026-04-20 14:13:09 +02:00
parent 530fd03c22
commit bb89de3c93

View File

@ -229,18 +229,43 @@ function _isHistoryTabActive() {
const tab = document.querySelector('.tab.active'); const tab = document.querySelector('.tab.active');
return !!(tab && tab.dataset.view === 'history'); return !!(tab && tab.dataset.view === 'history');
} }
document.querySelectorAll('.tab').forEach(tab => { // Cache the tab/view collections once and use event delegation on the parent
tab.addEventListener('click', () => { // so tab switches don't trigger three querySelectorAll walks per click.
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); (() => {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); const tabs = Array.from(document.querySelectorAll('.tab'));
const views = Array.from(document.querySelectorAll('.view'));
const tabsByView = {};
const viewsById = {};
for (const t of tabs) tabsByView[t.dataset.view] = t;
for (const v of views) viewsById[v.id] = v;
let activeTab = tabs.find(t => t.classList.contains('active')) || tabs[0];
const handle = (target) => {
const tab = target.closest('.tab');
if (!tab || tab === activeTab) return;
if (activeTab) {
activeTab.classList.remove('active');
const prevView = viewsById[`${activeTab.dataset.view}-view`];
if (prevView) prevView.classList.remove('active');
}
tab.classList.add('active'); tab.classList.add('active');
document.getElementById(`${tab.dataset.view}-view`).classList.add('active'); const nextView = viewsById[`${tab.dataset.view}-view`];
if (nextView) nextView.classList.add('active');
activeTab = tab;
if (tab.dataset.view === 'history') { if (tab.dataset.view === 'history') {
_historyDirty = false; _historyDirty = false;
loadHistory(); loadHistory();
} }
}); };
});
const tabBar = tabs[0] && tabs[0].parentElement;
if (tabBar) {
tabBar.addEventListener('click', (e) => handle(e.target));
} else {
// Fallback: bind per-tab if somehow no common parent
tabs.forEach(t => t.addEventListener('click', () => handle(t)));
}
})();
// --- Hoster selection --- // --- Hoster selection ---
function accountHasCreds(name, account) { function accountHasCreds(name, account) {
@ -1175,6 +1200,22 @@ function handleRowClick(e, row) {
// --- Context menu --- // --- Context menu ---
let alwaysOnTopState = false; let alwaysOnTopState = false;
// Cache hoster-counts for the context menu. Invalidated on structural changes
// to queueJobs (the length-based signature is good enough — a job's hoster
// never changes after it's created).
let _hosterCountsCache = { sig: '', result: new Map() };
function _getHosterCounts() {
const sig = `${queueJobs.length}`;
if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result;
const m = new Map();
for (let i = 0; i < queueJobs.length; i++) {
const h = queueJobs[i].hoster;
m.set(h, (m.get(h) || 0) + 1);
}
_hosterCountsCache = { sig, result: m };
return m;
}
function handleRowContextMenu(e, row) { function handleRowContextMenu(e, row) {
e.preventDefault(); e.preventDefault();
const jobId = row.dataset.jobId; const jobId = row.dataset.jobId;
@ -1204,11 +1245,11 @@ function showContextMenu(x, y) {
const startItem = menu.querySelector('[data-action="start-selected"]'); const startItem = menu.querySelector('[data-action="start-selected"]');
if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten'; if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten';
// Dynamic "delete by hoster" submenu // Dynamic "delete by hoster" submenu — cached count keyed by queue length
// so a right-click on a 5000-job queue doesn't rescan everything.
const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu'); const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu');
const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items'); const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items');
const hosterCounts = new Map(); const hosterCounts = _getHosterCounts();
queueJobs.forEach(j => hosterCounts.set(j.hoster, (hosterCounts.get(j.hoster) || 0) + 1));
deleteHosterContainer.innerHTML = ''; deleteHosterContainer.innerHTML = '';
if (hosterCounts.size > 0) { if (hosterCounts.size > 0) {
deleteHosterSubmenu.style.display = ''; deleteHosterSubmenu.style.display = '';
@ -2698,10 +2739,17 @@ async function saveSettings(options = {}) {
newHosterSettings[name] = hs; newHosterSettings[name] = hs;
} }
await window.api.saveHosterSettings(newHosterSettings); // Fire both saves in parallel instead of serializing the two IPC round-trips.
await window.api.saveGlobalSettings(globalSettings); // Skip the getConfig refetch — we just wrote it, we know the new state, and
config = await window.api.getConfig(); // the round-trip added 100200ms of UI stall per keystroke (autosave fires
hosterSettings = config.hosterSettings || {}; // on every input change).
await Promise.all([
window.api.saveHosterSettings(newHosterSettings),
window.api.saveGlobalSettings(globalSettings)
]);
config.hosterSettings = newHosterSettings;
config.globalSettings = globalSettings;
hosterSettings = newHosterSettings;
clearTimeout(settingsSaveTimer); clearTimeout(settingsSaveTimer);
// Start/stop folder monitor based on settings // Start/stop folder monitor based on settings
@ -3358,9 +3406,11 @@ function renderRecentUploadsPanel() {
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) {
// Use already-sorted DOM order (cheap) instead of re-sorting the full array. // Reuse the already-sorted array from the sort cache instead of
const sortedOrders = Array.from(tbody.querySelectorAll('.recent-file-row')) // querying every .recent-file-row in the DOM (O(visible) vs O(N)
.map(r => parseInt(r.dataset.order, 10)); // on large panels).
const sortedOrders = (_recentSortCache.result || sortRecentFiles(sessionFilesData))
.map(r => r.order);
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) {