From 7e828a2ec19239304069aee582a5180db226665a Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 20 Aug 2026 03:17:37 +0100 Subject: [PATCH] feat(webui): select and update several apps from the Updates tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updating one app and updating everything were both already there — a per-row Update button and an Update all in the tab header. What was missing is the middle: choosing three of eight. Both existing controls are gated on update_available, so with a fully current fleet the tab shows neither and reads as though it cannot update anything at all, which is what prompted this. Checkboxes appear only on rows that actually have something to apply. Offering one on a current app would be a choice with no outcome, and "selected 6, updated 2" is a worse answer than not offering the four. The column is still reserved on every row, hidden — otherwise the icons shift left and right as scans land and rows change state. applySelected filters the chosen names against update_available rather than trusting them: a selection can outlive the scan that justified it, and asking to update an app with nothing to apply would spend a snapshot to achieve nothing. Update all now routes through the same function, so there is one path, one task, and the same per-app contract — snapshot first, roll back on failure. Two details that only show up when you use it. The checkbox sits inside the row head, which is itself the expander target, so its handler has to claim the click or picking an app also opens its detail panel. And the count is refreshed in place instead of re-rendering the tab, because a re-render rebuilds the checkboxes under the pointer mid-selection. The bar holds its space at zero selected rather than appearing on the first tick, which would shift the list down under the cursor. Co-Authored-By: Claude Opus 5 --- .../components/apps/core/css/apps.css | 19 ++++++ .../apps/overview/js/overview-manager.js | 61 ++++++++++++++++++- .../components/updater/js/updater-page.js | 12 ++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index a63fb57..4fdc49b 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -832,3 +832,22 @@ color: var(--text-primary); outline: none; } + +/* Updates tab multi-select. The checkbox column is always present so rows stay + aligned whether or not an app has something to apply — a column that appears + and disappears per row makes the list jitter as scans land. */ +.ov-pick { display: inline-flex; align-items: center; justify-content: center; width: 18px; margin-right: 6px; flex: 0 0 auto; } +.ov-pick-empty { visibility: hidden; } +.ov-pick-box { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent, #29b6f6); } + +/* The bar holds its space even at zero selected: appearing on first tick would + shift the whole list down under the pointer mid-click. */ +.ov-selbar { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; margin: 0 0 10px; + border: 1px dashed rgba(var(--text-rgb), 0.18); border-radius: 10px; + opacity: 0.55; transition: opacity 120ms ease, border-color 120ms ease; +} +.ov-selbar.is-on { opacity: 1; border-style: solid; border-color: rgba(var(--text-rgb), 0.3); } +.ov-selbar-count { font-size: 13px; color: var(--text-secondary); } +.ov-selbar .updater-btn[disabled] { opacity: 0.45; cursor: not-allowed; } diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index a0b1e2d..be58c85 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -38,6 +38,7 @@ class OverviewManager { this.tabs = null; // TabController (show/hide only) this.filter = 'all'; // Updates-tab chip: all | updates | security this.board = null; // Overview-tab chip: null = auto | all | action + this.selected = new Set(); // Updates-tab multi-select, app slugs this.current = null; } @@ -315,6 +316,25 @@ class OverviewManager { const oa = e.target.closest('[data-overview-action]'); if (oa) { switch (oa.dataset.overviewAction) { + // The checkbox lives inside the row head, which is itself the expander + // target — so this has to claim the click, or picking an app would also + // open its detail panel every time. + case 'select': { + e.stopPropagation(); + const slug = oa.dataset.app; + if (!slug) break; + if (oa.checked) this.selected.add(slug); else this.selected.delete(slug); + this._syncSelectionBar(); + return; + } + case 'select-clear': + this.selected.clear(); + this._applyTab('updates'); + return; + case 'update-selected': + if (this.updater) this.updater.applySelected([...this.selected]); + this.selected.clear(); + return; case 'goto': // Whole-row navigation is a convenience over the row's own button — // don't fire it when the click was really the end of a text drag @@ -576,6 +596,38 @@ class OverviewManager { // ---- Updates tab (expander table + filter chips) ------------------------- + // Prune selections whose app no longer has an update — a rescan can land + // while a selection is open, and a stale pick would silently do nothing. + _liveSelection() { + const up = this.updater; + if (!up) return []; + const avail = new Set(up.apps.filter((a) => a.update_available).map((a) => a.name)); + for (const s of [...this.selected]) if (!avail.has(s)) this.selected.delete(s); + return [...this.selected]; + } + + // Update the bar in place. Re-rendering the whole tab on every tick would + // rebuild the checkboxes underneath the pointer and lose focus mid-selection. + _syncSelectionBar() { + const bar = document.querySelector('.ov-selbar'); + if (!bar) return; + const n = this._liveSelection().length; + bar.classList.toggle('is-on', n > 0); + const label = bar.querySelector('.ov-selbar-count'); + if (label) label.textContent = n === 1 ? '1 app selected' : `${n} apps selected`; + const btn = bar.querySelector('[data-overview-action="update-selected"]'); + if (btn) btn.disabled = n === 0; + } + + renderSelectionBar() { + const n = this._liveSelection().length; + return `
+ ${n === 1 ? '1 app selected' : `${n} apps selected`} + + +
`; + } + renderUpdates() { const up = this.updater; if (!up || !up.apps.length) return `
No installed apps to track.
`; @@ -604,6 +656,7 @@ class OverviewManager {
${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('newer', 'Newer', nNew)}${chip('security', 'Security', nSec)}
+ ${nUpd ? this.renderSelectionBar() : ''}
${rows}
`; } @@ -698,9 +751,15 @@ class OverviewManager { const updBtn = a.update_available ? `` : ''; + // Only rows with something to apply are selectable. A checkbox on a row + // that is already current would offer a choice with no outcome, and + // "selected 6, updated 2" is a worse answer than not offering the 4. + const pick = a.update_available + ? `` + : ''; return `