feat(webui): select and update several apps from the Updates tab
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 <noreply@anthropic.com>
This commit is contained in:
parent
9c8f0782e1
commit
7e828a2ec1
@ -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; }
|
||||
|
||||
@ -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 `<div class="ov-selbar${n > 0 ? ' is-on' : ''}">
|
||||
<span class="ov-selbar-count">${n === 1 ? '1 app selected' : `${n} apps selected`}</span>
|
||||
<button class="updater-btn updater-btn-primary" data-overview-action="update-selected"${n === 0 ? ' disabled' : ''}>Update selected</button>
|
||||
<button class="updater-btn" data-overview-action="select-clear">Clear</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderUpdates() {
|
||||
const up = this.updater;
|
||||
if (!up || !up.apps.length) return `<div class="updater-empty">No installed apps to track.</div>`;
|
||||
@ -604,6 +656,7 @@ class OverviewManager {
|
||||
<div class="updater-toolbar ov-toolbar">
|
||||
<div class="ov-chips">${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('newer', 'Newer', nNew)}${chip('security', 'Security', nSec)}</div>
|
||||
</div>
|
||||
${nUpd ? this.renderSelectionBar() : ''}
|
||||
<div class="updater-list ov-updates-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
@ -698,9 +751,15 @@ class OverviewManager {
|
||||
const updBtn = a.update_available
|
||||
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${slug}">Update</button>`
|
||||
: '';
|
||||
// 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
|
||||
? `<span class="ov-pick"><input type="checkbox" class="ov-pick-box" data-overview-action="select" data-app="${slug}"${this.selected.has(a.name) ? ' checked' : ''} aria-label="Select ${name} for update"></span>`
|
||||
: '<span class="ov-pick ov-pick-empty" aria-hidden="true"></span>';
|
||||
return `<div class="updater-row ov-row" data-app="${slug}">
|
||||
<div class="updater-row-head ov-row-head" data-overview-action="toggle" data-app="${slug}" role="button" tabindex="0" aria-expanded="false" aria-controls="ov-detail-${slug}">
|
||||
<img class="ov-row-icon" src="/core/icons/apps/${slug}.svg" alt="" onerror="this.style.display='none'">
|
||||
${pick}<img class="ov-row-icon" src="/core/icons/apps/${slug}.svg" alt="" onerror="this.style.display='none'">
|
||||
<span class="updater-row-name">${name}</span> ${status} ${sev} ${pol} ${newer} ${stale}
|
||||
<span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span>
|
||||
<span class="ov-row-actions">${updBtn}<button class="ov-details-btn" data-overview-action="toggle" data-app="${slug}" tabindex="-1" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg><span>Details</span></button></span>
|
||||
|
||||
@ -312,6 +312,18 @@ class UpdaterPage {
|
||||
}
|
||||
applyAll() {
|
||||
const list = this.apps.filter(a => a.update_available).map(a => a.name);
|
||||
this.applySelected(list);
|
||||
}
|
||||
|
||||
// Update a chosen subset. Same task and same per-app contract as Update all —
|
||||
// each app is snapshotted before it is touched and rolled back on failure —
|
||||
// so picking three of eight is a narrower choice, not a lesser guarantee.
|
||||
// Filtered against update_available rather than trusted: a selection can
|
||||
// outlive the scan that justified it, and asking to update an app that has
|
||||
// nothing to apply would spend a snapshot to achieve nothing.
|
||||
applySelected(names) {
|
||||
const want = new Set(names || []);
|
||||
const list = this.apps.filter(a => a.update_available && want.has(a.name)).map(a => a.name);
|
||||
if (!list.length) { this.toast('Everything is up to date.', 'info'); return; }
|
||||
this.dispatch('updater_apply_all', { apps: list.join(',') }, `Updating ${list.length} app(s) — each is snapshotted first…`);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user