feat: add selected update installs and polished UI

This commit is contained in:
2026-05-03 23:04:30 +02:00
parent 137290e3d7
commit e8d4d2e400
4 changed files with 211 additions and 13 deletions

View File

@@ -51,6 +51,7 @@ The GUI opens in your browser and lets you:
- refresh the update list - refresh the update list
- search and filter updates by source - search and filter updates by source
- start update installation in a terminal - start update installation in a terminal
- select individual packages and install only those when you explicitly want that workflow
- hide noisy packages from the active update count - hide noisy packages from the active update count
- see system readiness, Pacman lock state, disk space, AUR helper, terminal, and kernel version - see system readiness, Pacman lock state, disk space, AUR helper, terminal, and kernel version
- enable or disable AUR checks - enable or disable AUR checks
@@ -58,6 +59,8 @@ The GUI opens in your browser and lets you:
- configure automatic refresh - configure automatic refresh
- choose the terminal used for installing updates - choose the terminal used for installing updates
Selective package installs use `pacman -S --needed` or the configured AUR helper with `-S --needed`. On Arch, full system updates are still the safer default because partial upgrades can cause dependency mismatches.
## Desktop App ## Desktop App
Install the Electron dependencies once: Install the Electron dependencies once:

View File

@@ -17,7 +17,8 @@ const indexHTML = `<!doctype html>
</div> </div>
<div class="actions"> <div class="actions">
<button id="refreshBtn" type="button" title="Updates pruefen">Refresh</button> <button id="refreshBtn" type="button" title="Updates pruefen">Refresh</button>
<button id="installBtn" type="button">Installieren</button> <button id="installSelectedBtn" type="button" disabled>Auswahl installieren</button>
<button id="installBtn" type="button">Alle installieren</button>
</div> </div>
</header> </header>
@@ -57,11 +58,19 @@ const indexHTML = `<!doctype html>
</select> </select>
</div> </div>
</div> </div>
<div class="selection-bar">
<label class="select-all">
<input id="selectAll" type="checkbox">
<span id="selectionText">0 Updates ausgewaehlt</span>
</label>
<span>Selektive Updates koennen unter Arch riskant sein. Vollupdate bleibt empfohlen.</span>
</div>
<div id="warnings" class="warnings" hidden></div> <div id="warnings" class="warnings" hidden></div>
<div id="emptyState" class="empty">Keine passenden Updates gefunden.</div> <div id="emptyState" class="empty">Keine passenden Updates gefunden.</div>
<table id="updatesTable" hidden> <table id="updatesTable" hidden>
<thead> <thead>
<tr> <tr>
<th></th>
<th>Quelle</th> <th>Quelle</th>
<th>Paket</th> <th>Paket</th>
<th>Aktuell</th> <th>Aktuell</th>
@@ -161,14 +170,16 @@ const indexHTML = `<!doctype html>
const appCSS = ` const appCSS = `
:root { :root {
color-scheme: dark; color-scheme: dark;
--bg: #111317; --bg: #0f1215;
--panel: #191d23; --panel: #191e25;
--panel-soft: #222832; --panel-soft: #232a34;
--panel-lift: #202732;
--text: #f4f7fb; --text: #f4f7fb;
--muted: #9ba7b6; --muted: #9ba7b6;
--line: #303844; --line: #303844;
--accent: #44d19d; --accent: #44d19d;
--accent-strong: #2eb984; --accent-strong: #2eb984;
--blue: #6ea8fe;
--warn: #f4c95d; --warn: #f4c95d;
--danger: #ff6f61; --danger: #ff6f61;
} }
@@ -182,7 +193,9 @@ body {
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
color: var(--text); color: var(--text);
background: var(--bg); background:
radial-gradient(circle at top left, rgba(68, 209, 157, 0.14), transparent 34rem),
linear-gradient(180deg, #13171d 0%, var(--bg) 22rem);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
@@ -249,10 +262,12 @@ button {
color: var(--text); color: var(--text);
background: var(--panel-soft); background: var(--panel-soft);
cursor: pointer; cursor: pointer;
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease;
} }
button:hover { button:hover {
border-color: var(--accent); border-color: var(--accent);
transform: translateY(-1px);
} }
button:disabled { button:disabled {
@@ -273,6 +288,17 @@ form button:hover {
background: var(--accent-strong); background: var(--accent-strong);
} }
#installSelectedBtn {
border-color: rgba(110, 168, 254, 0.42);
color: #dcebff;
background: rgba(110, 168, 254, 0.12);
font-weight: 720;
}
#installSelectedBtn:hover {
border-color: var(--blue);
}
.stats { .stats {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -285,6 +311,7 @@ form button:hover {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.18);
} }
.stat { .stat {
@@ -292,6 +319,7 @@ form button:hover {
gap: 6px; gap: 6px;
min-height: 82px; min-height: 82px;
padding: 14px; padding: 14px;
background: linear-gradient(180deg, var(--panel-lift), var(--panel));
} }
.stat span, .stat span,
@@ -345,6 +373,27 @@ select {
width: min(32vw, 320px); width: min(32vw, 320px);
} }
.selection-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 46px;
padding: 0 16px;
border-bottom: 1px solid var(--line);
color: var(--warn);
background: rgba(244, 201, 93, 0.07);
}
.select-all {
display: flex;
grid-template-columns: none;
align-items: center;
gap: 10px;
color: var(--text);
white-space: nowrap;
}
.warnings { .warnings {
margin: 14px 16px 0; margin: 14px 16px 0;
padding: 12px; padding: 12px;
@@ -384,7 +433,12 @@ td {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
td:first-child { .updates-panel td:first-child {
width: 46px;
color: var(--text);
}
.updates-panel td:nth-child(2) {
width: 92px; width: 92px;
color: var(--accent); color: var(--accent);
font-weight: 720; font-weight: 720;
@@ -399,6 +453,10 @@ tr:last-child td {
border-bottom: 0; border-bottom: 0;
} }
tr.selected td {
background: rgba(110, 168, 254, 0.08);
}
.ghost { .ghost {
min-height: 34px; min-height: 34px;
padding: 0 10px; padding: 0 10px;
@@ -477,12 +535,16 @@ input[type="checkbox"] {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.selection-bar {
display: grid;
}
#searchInput { #searchInput {
width: 100%; width: 100%;
} }
th:nth-child(3), th:nth-child(4),
td:nth-child(3) { td:nth-child(4) {
display: none; display: none;
} }
} }
@@ -496,6 +558,9 @@ const diskFree = document.querySelector("#diskFree");
const kernel = document.querySelector("#kernel"); const kernel = document.querySelector("#kernel");
const refreshBtn = document.querySelector("#refreshBtn"); const refreshBtn = document.querySelector("#refreshBtn");
const installBtn = document.querySelector("#installBtn"); const installBtn = document.querySelector("#installBtn");
const installSelectedBtn = document.querySelector("#installSelectedBtn");
const selectAll = document.querySelector("#selectAll");
const selectionText = document.querySelector("#selectionText");
const updatesTable = document.querySelector("#updatesTable"); const updatesTable = document.querySelector("#updatesTable");
const updatesBody = document.querySelector("#updatesBody"); const updatesBody = document.querySelector("#updatesBody");
const ignoredPanel = document.querySelector("#ignoredPanel"); const ignoredPanel = document.querySelector("#ignoredPanel");
@@ -520,6 +585,7 @@ const saveState = document.querySelector("#saveState");
let currentData = null; let currentData = null;
let refreshTimer = null; let refreshTimer = null;
let selectedPackages = new Set();
async function request(path, options = {}) { async function request(path, options = {}) {
const response = await fetch(path, { const response = await fetch(path, {
@@ -568,6 +634,22 @@ function renderTable() {
updatesBody.replaceChildren(); updatesBody.replaceChildren();
for (const pkg of packages) { for (const pkg of packages) {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.classList.toggle("selected", selectedPackages.has(pkg.Name));
const selectCell = document.createElement("td");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = selectedPackages.has(pkg.Name);
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
selectedPackages.add(pkg.Name);
} else {
selectedPackages.delete(pkg.Name);
}
renderTable();
});
selectCell.append(checkbox);
row.append(selectCell);
row.append(cell(pkg.Source)); row.append(cell(pkg.Source));
row.append(cell(pkg.Name)); row.append(cell(pkg.Name));
row.append(cell(pkg.Current || "-")); row.append(cell(pkg.Current || "-"));
@@ -586,6 +668,7 @@ function renderTable() {
updatesTable.hidden = packages.length === 0; updatesTable.hidden = packages.length === 0;
emptyState.hidden = packages.length !== 0; emptyState.hidden = packages.length !== 0;
updateSelectionUi(packages);
} }
function renderIgnored() { function renderIgnored() {
@@ -618,6 +701,8 @@ function cell(value) {
function render(data) { function render(data) {
currentData = data; currentData = data;
const availableNames = new Set(data.packages.filter((pkg) => !pkg.Ignored).map((pkg) => pkg.Name));
selectedPackages = new Set([...selectedPackages].filter((name) => availableNames.has(name)));
summary.textContent = data.summary; summary.textContent = data.summary;
activeCount.textContent = data.total; activeCount.textContent = data.total;
ignoredCount.textContent = data.ignored_total; ignoredCount.textContent = data.ignored_total;
@@ -645,6 +730,14 @@ function render(data) {
scheduleAutoRefresh(); scheduleAutoRefresh();
} }
function updateSelectionUi(packages = visiblePackages()) {
const selectedCount = selectedPackages.size;
selectionText.textContent = selectedCount === 1 ? "1 Update ausgewaehlt" : selectedCount + " Updates ausgewaehlt";
installSelectedBtn.disabled = selectedCount === 0 || currentData?.system.pacman_locked;
selectAll.checked = packages.length > 0 && packages.every((pkg) => selectedPackages.has(pkg.Name));
selectAll.indeterminate = packages.some((pkg) => selectedPackages.has(pkg.Name)) && !selectAll.checked;
}
async function loadStatus() { async function loadStatus() {
refreshBtn.disabled = true; refreshBtn.disabled = true;
summary.textContent = "Pruefe Updates..."; summary.textContent = "Pruefe Updates...";
@@ -677,11 +770,22 @@ async function setIgnored(name, ignored) {
refreshBtn.addEventListener("click", loadStatus); refreshBtn.addEventListener("click", loadStatus);
searchInput.addEventListener("input", renderTable); searchInput.addEventListener("input", renderTable);
sourceFilter.addEventListener("change", renderTable); sourceFilter.addEventListener("change", renderTable);
selectAll.addEventListener("change", () => {
const packages = visiblePackages();
for (const pkg of packages) {
if (selectAll.checked) {
selectedPackages.add(pkg.Name);
} else {
selectedPackages.delete(pkg.Name);
}
}
renderTable();
});
installBtn.addEventListener("click", async () => { installBtn.addEventListener("click", async () => {
installBtn.disabled = true; installBtn.disabled = true;
try { try {
await request("/api/install", { method: "POST" }); await request("/api/install", { method: "POST", body: "{}" });
summary.textContent = "Installation im Terminal gestartet."; summary.textContent = "Installation im Terminal gestartet.";
} catch (error) { } catch (error) {
summary.textContent = error.message; summary.textContent = error.message;
@@ -690,6 +794,30 @@ installBtn.addEventListener("click", async () => {
} }
}); });
installSelectedBtn.addEventListener("click", async () => {
const packages = [...selectedPackages];
if (packages.length === 0) {
return;
}
const ok = window.confirm("Ausgewaehlte Pakete gezielt installieren? Unter Arch ist ein vollstaendiges Systemupdate meistens sicherer.");
if (!ok) {
return;
}
installSelectedBtn.disabled = true;
try {
await request("/api/install", {
method: "POST",
body: JSON.stringify({ packages }),
});
summary.textContent = "Ausgewaehlte Installation im Terminal gestartet.";
} catch (error) {
summary.textContent = error.message;
} finally {
installSelectedBtn.disabled = false;
}
});
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
saveState.textContent = "Speichere..."; saveState.textContent = "Speichere...";

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net" "net"
"net/http" "net/http"
@@ -50,6 +51,10 @@ type ignoreRequest struct {
Ignored bool `json:"ignored"` Ignored bool `json:"ignored"`
} }
type installRequest struct {
Packages []string `json:"packages"`
}
func Run(configPath, statePath string, openBrowser bool) error { func Run(configPath, statePath string, openBrowser bool) error {
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@@ -209,7 +214,15 @@ func (s *Server) install(w http.ResponseWriter, r *http.Request) {
return return
} }
name, args, err := terminalCommand(cfg) var req installRequest
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, err, http.StatusBadRequest)
return
}
}
name, args, err := terminalCommand(cfg, req.Packages)
if err != nil { if err != nil {
writeError(w, err, http.StatusBadRequest) writeError(w, err, http.StatusBadRequest)
return return
@@ -242,7 +255,7 @@ func setIgnored(names []string, name string, ignored bool) []string {
} }
func systemStatus(cfg config.Config) SystemStatus { func systemStatus(cfg config.Config) SystemStatus {
terminal, _, _ := terminalCommand(cfg) terminal, _, _ := terminalCommand(cfg, nil)
return SystemStatus{ return SystemStatus{
AURHelper: updater.AURHelper(), AURHelper: updater.AURHelper(),
Terminal: terminal, Terminal: terminal,
@@ -298,10 +311,22 @@ func kernelVersion() string {
return strings.TrimSpace(string(out)) return strings.TrimSpace(string(out))
} }
func terminalCommand(cfg config.Config) (string, []string, error) { func terminalCommand(cfg config.Config, packages []string) (string, []string, error) {
updateCommand := "lazy-update-manager update; printf '\\nDone. Press enter to close... '; read _" updateCommand := "lazy-update-manager update; printf '\\nDone. Press enter to close... '; read _"
if len(packages) > 0 {
selected := shellPackageList(packages)
if selected == "" {
return "", nil, errors.New("no valid packages selected")
}
updateCommand = "sudo pacman -S --needed " + selected + "; printf '\\nDone. Press enter to close... '; read _"
if helper := updater.AURHelper(); helper != "" && cfg.CheckAUR {
updateCommand = helper + " -S --needed " + selected + "; printf '\\nDone. Press enter to close... '; read _"
}
}
if helper := updater.AURHelper(); helper != "" && cfg.CheckAUR { if helper := updater.AURHelper(); helper != "" && cfg.CheckAUR {
updateCommand = helper + " -Syu; printf '\\nDone. Press enter to close... '; read _" if len(packages) == 0 {
updateCommand = helper + " -Syu; printf '\\nDone. Press enter to close... '; read _"
}
} }
candidates := []string{} candidates := []string{}
@@ -334,6 +359,37 @@ func terminalCommand(cfg config.Config) (string, []string, error) {
return "", nil, errors.New("no supported terminal found; set one in settings") return "", nil, errors.New("no supported terminal found; set one in settings")
} }
func shellPackageList(packages []string) string {
quoted := []string{}
seen := map[string]bool{}
for _, pkg := range packages {
pkg = strings.TrimSpace(pkg)
if pkg == "" || seen[pkg] || !validPackageName(pkg) {
continue
}
seen[pkg] = true
quoted = append(quoted, shellQuote(pkg))
}
return strings.Join(quoted, " ")
}
func validPackageName(value string) bool {
for _, r := range value {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
continue
}
if strings.ContainsRune("@._+-", r) {
continue
}
return false
}
return true
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func openURL(url string) error { func openURL(url string) error {
switch runtime.GOOS { switch runtime.GOOS {
case "linux": case "linux":

View File

@@ -0,0 +1,11 @@
package gui
import "testing"
func TestShellPackageList(t *testing.T) {
got := shellPackageList([]string{"vapoursynth", "libastal-4-git", "vapoursynth", "bad package"})
want := "'vapoursynth' 'libastal-4-git'"
if got != want {
t.Fatalf("shellPackageList() = %q, want %q", got, want)
}
}