diff --git a/package.json b/package.json index d8ecd46..1131d91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "3.3.103", + "version": "3.3.104", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index 64bca40..62b9b59 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -4738,54 +4738,67 @@ function _buildRecentRowHtml(row) { let _recentLastRenderedSig = ''; let _recentLastRenderedLen = 0; let _recentPendingAppends = 0; +let _recentWorking = []; +let _recentLastRange = { start: -1, end: -1 }; +let _recentScrollQueued = false; + +function _onRecentScroll() { + if (_recentScrollQueued) return; + _recentScrollQueued = true; + requestAnimationFrame(() => { _recentScrollQueued = false; _renderRecentVirtualRows(); }); +} + +function _renderRecentVirtualRows() { + const wrap = document.querySelector('.recent-files-table-wrap'); + const tbody = document.getElementById('recentFilesBody'); + if (!wrap || !tbody) return; + const total = _recentWorking.length; + if (!total) return; + const scrollTop = wrap.scrollTop; + const viewportHeight = Math.max(wrap.clientHeight, 400); + const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN); + const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN); + if (startIdx === _recentLastRange.start && endIdx === _recentLastRange.end) return; + _recentLastRange = { start: startIdx, end: endIdx }; + const topPad = startIdx * VIRTUAL_ROW_HEIGHT; + const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT); + const parts = []; + if (topPad > 0) parts.push(``); + for (let i = startIdx; i < endIdx; i++) parts.push(_buildRecentRowHtml(_recentWorking[i])); + if (bottomPad > 0) parts.push(``); + tbody.innerHTML = parts.join(''); +} function renderRecentUploadsPanel(appendOnly = false) { const tbody = document.getElementById('recentFilesBody'); if (!tbody) return; - const pendingAppends = _recentPendingAppends; _recentPendingAppends = 0; + const wrap = tbody.closest('.recent-files-table-wrap'); + if (!sessionFilesData.length) { tbody.innerHTML = 'Noch keine Uploads in dieser Session.'; - _recentLastRenderedSig = ''; - _recentLastRenderedLen = 0; - return; - } - - const rows = sortRecentFiles(sessionFilesData); - const sig = `${recentSortState.key}|${recentSortState.direction}`; - const dateDescAppendOnly = appendOnly - && pendingAppends > 0 - && sig === 'date|desc' - && _recentLastRenderedSig === sig - && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen; - - const wrap = tbody.closest('.recent-files-table-wrap'); - const wasAtTop = !wrap || wrap.scrollTop <= 48; - - let wasAppendOnly = false; - if (dateDescAppendOnly) { - const added = Math.min(pendingAppends, rows.length); - let html = ''; - for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]); - tbody.insertAdjacentHTML('afterbegin', html); - let evict = (_recentLastRenderedLen + added) - rows.length; - while (evict > 0) { - const last = tbody.lastElementChild; - if (!last) break; - last.remove(); - evict--; - } - wasAppendOnly = true; + _recentWorking = []; + _recentLastRange = { start: -1, end: -1 }; } else { - tbody.innerHTML = rows.map(_buildRecentRowHtml).join(''); + const prevLen = _recentWorking.length; + _recentWorking = sortRecentFiles(sessionFilesData); + _recentLastRange = { start: -1, end: -1 }; + const sig = `${recentSortState.key}|${recentSortState.direction}`; + if (wrap) { + const added = _recentWorking.length - prevLen; + if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0; + else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT; + } + _renderRecentVirtualRows(); } - if (wrap && sig === 'date|desc' && wasAtTop) wrap.scrollTop = 0; - _recentLastRenderedSig = sig; - _recentLastRenderedLen = rows.length; // Event delegation – bind once, not per-row if (!_recentListenersBound) { _recentListenersBound = true; + if (wrap) { + wrap.addEventListener('scroll', _onRecentScroll, { passive: true }); + if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onRecentScroll).observe(wrap); + } tbody.addEventListener('click', (e) => { const tr = e.target.closest('.recent-file-row'); if (!tr) return; @@ -4824,8 +4837,7 @@ function renderRecentUploadsPanel(appendOnly = false) { }); } - // Sort headers only change when the sort state changes — skip on appends. - if (!wasAppendOnly) updateRecentSortHeaders(); + updateRecentSortHeaders(); } const HISTORY_RENDER_CAP = 2000; diff --git a/renderer/styles.css b/renderer/styles.css index f2e6ce3..e5b9420 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -657,6 +657,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel .recent-file-row { cursor: pointer; transition: background 0.15s; + height: 28px; } .recent-file-row:hover { background: rgba(255, 255, 255, 0.03); diff --git a/tasks/todo.md b/tasks/todo.md index 817434c..41ab0f7 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,3 +1,40 @@ +# v3.3.104 — virtualize the Recent-uploads panel (the last non-virtual table) + +v3.3.103 log (real 2464-job, 4-hoster batch → 95 concurrent): the statSync fix HELD (batch-start main spike +336→231ms with 10× more jobs), and the whole 90s ramp to 95 active was PRISTINE (mean ~11ms, fps=32, +longtasks=0). Residual: rapidly clicking tabs DURING the 95-active upload → renderer-longtask 210-221ms +(proc=0ms = layout). Cause: the Recent-uploads panel (renderRecentUploadsPanel) rendered ALL sessionFilesData +rows (≤2000) into the DOM non-virtualized — the exact analog of the History table pre-v3.3.102. Switching to +that view laid out ~2000 rows. + +Workflow w23318hm0 hit transient 529 overload (no cached results); did the fix directly using the proven +History-virtualization template + Playwright empirical verification (stronger than agent review for layout). + +SHIPPED v3.3.104 (renderer/app.js + styles.css): +- Virtualized renderRecentUploadsPanel mirroring History/_renderVirtualRows: tbody#recentFilesBody gets only + ~visible rows + top/bottom spacer (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler (_onRecentScroll + rAF-coalesced) + ResizeObserver on .recent-files-table-wrap (doubles as show-trigger). _recentWorking holds + the sorted set. DROPPED the insertAdjacentHTML append-only fast path (a ~40-row window re-render is cheap); + every render re-renders the visible window. Scroll-position preserved on prepend (date|desc: scrollTop=0 at + top, else += added*ROW_HEIGHT). +- SELECTION SAFE: _buildRecentRowHtml already stamps `selected` from selectedRecentIds.has(row.order) per row, + so off-screen-selected rows render selected when scrolled in; selectedRecentIds stays the source of truth; + shift-select already uses _recentSortCache (not the DOM). applyRecentSelectionClasses toggling only visible + rows is correct. +- styles.css: .recent-file-row { height: 28px } so the virtualization math is exact (table already had + table-layout:fixed, so no column-jump fix needed unlike History). +- Empty-state guard: _renderRecentVirtualRows returns early when total=0 so it never wipes the "Noch keine + Uploads" message. +- PLAYWRIGHT-VERIFIED @2000 rows (bounded container): show-cost 118ms→2.4ms, DOM stays 29-39 rows, scroll maps + correctly (row1500→window@1486), scrollHeight exact (56014≈56000), off-screen-selected renders with class, + row height exactly 28. 407 tests pass, clean boot. + +Every large table is now virtualized (Queue, History, Recent). DEFERRED still (one-time/minor): batch-start +~500ms first-render + residual 231ms main spike for 2464 jobs (one-time per batch); first-get-history-after- +batch parse-cache/JSONL. + +--- + # v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm) v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch),