diff --git a/README.md b/README.md index 93968b6..63f95da 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ The GUI opens in your browser and lets you: - refresh the update list - search and filter updates by source - 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 - see system readiness, Pacman lock state, disk space, AUR helper, terminal, and kernel version - enable or disable AUR checks @@ -58,6 +59,8 @@ The GUI opens in your browser and lets you: - configure automatic refresh - 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 Install the Electron dependencies once: diff --git a/internal/gui/assets.go b/internal/gui/assets.go index 618a60e..cadc542 100644 --- a/internal/gui/assets.go +++ b/internal/gui/assets.go @@ -17,7 +17,8 @@ const indexHTML = `
| Quelle | Paket | Aktuell | @@ -161,14 +170,16 @@ const indexHTML = ` const appCSS = ` :root { color-scheme: dark; - --bg: #111317; - --panel: #191d23; - --panel-soft: #222832; + --bg: #0f1215; + --panel: #191e25; + --panel-soft: #232a34; + --panel-lift: #202732; --text: #f4f7fb; --muted: #9ba7b6; --line: #303844; --accent: #44d19d; --accent-strong: #2eb984; + --blue: #6ea8fe; --warn: #f4c95d; --danger: #ff6f61; } @@ -182,7 +193,9 @@ body { min-width: 320px; min-height: 100vh; 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; } @@ -249,10 +262,12 @@ button { color: var(--text); background: var(--panel-soft); cursor: pointer; + transition: border-color 140ms ease, background 140ms ease, transform 140ms ease; } button:hover { border-color: var(--accent); + transform: translateY(-1px); } button:disabled { @@ -273,6 +288,17 @@ form button:hover { 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 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -285,6 +311,7 @@ form button:hover { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.18); } .stat { @@ -292,6 +319,7 @@ form button:hover { gap: 6px; min-height: 82px; padding: 14px; + background: linear-gradient(180deg, var(--panel-lift), var(--panel)); } .stat span, @@ -345,6 +373,27 @@ select { 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 { margin: 14px 16px 0; padding: 12px; @@ -384,7 +433,12 @@ td { overflow-wrap: anywhere; } -td:first-child { +.updates-panel td:first-child { + width: 46px; + color: var(--text); +} + +.updates-panel td:nth-child(2) { width: 92px; color: var(--accent); font-weight: 720; @@ -399,6 +453,10 @@ tr:last-child td { border-bottom: 0; } +tr.selected td { + background: rgba(110, 168, 254, 0.08); +} + .ghost { min-height: 34px; padding: 0 10px; @@ -477,12 +535,16 @@ input[type="checkbox"] { grid-template-columns: 1fr 1fr; } + .selection-bar { + display: grid; + } + #searchInput { width: 100%; } - th:nth-child(3), - td:nth-child(3) { + th:nth-child(4), + td:nth-child(4) { display: none; } } @@ -496,6 +558,9 @@ const diskFree = document.querySelector("#diskFree"); const kernel = document.querySelector("#kernel"); const refreshBtn = document.querySelector("#refreshBtn"); 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 updatesBody = document.querySelector("#updatesBody"); const ignoredPanel = document.querySelector("#ignoredPanel"); @@ -520,6 +585,7 @@ const saveState = document.querySelector("#saveState"); let currentData = null; let refreshTimer = null; +let selectedPackages = new Set(); async function request(path, options = {}) { const response = await fetch(path, { @@ -568,6 +634,22 @@ function renderTable() { updatesBody.replaceChildren(); for (const pkg of packages) { 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.Name)); row.append(cell(pkg.Current || "-")); @@ -586,6 +668,7 @@ function renderTable() { updatesTable.hidden = packages.length === 0; emptyState.hidden = packages.length !== 0; + updateSelectionUi(packages); } function renderIgnored() { @@ -618,6 +701,8 @@ function cell(value) { function render(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; activeCount.textContent = data.total; ignoredCount.textContent = data.ignored_total; @@ -645,6 +730,14 @@ function render(data) { 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() { refreshBtn.disabled = true; summary.textContent = "Pruefe Updates..."; @@ -677,11 +770,22 @@ async function setIgnored(name, ignored) { refreshBtn.addEventListener("click", loadStatus); searchInput.addEventListener("input", 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.disabled = true; try { - await request("/api/install", { method: "POST" }); + await request("/api/install", { method: "POST", body: "{}" }); summary.textContent = "Installation im Terminal gestartet."; } catch (error) { 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) => { event.preventDefault(); saveState.textContent = "Speichere..."; diff --git a/internal/gui/server.go b/internal/gui/server.go index bf96ef3..5577ac3 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net" "net/http" @@ -50,6 +51,10 @@ type ignoreRequest struct { Ignored bool `json:"ignored"` } +type installRequest struct { + Packages []string `json:"packages"` +} + func Run(configPath, statePath string, openBrowser bool) error { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -209,7 +214,15 @@ func (s *Server) install(w http.ResponseWriter, r *http.Request) { 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 { writeError(w, err, http.StatusBadRequest) return @@ -242,7 +255,7 @@ func setIgnored(names []string, name string, ignored bool) []string { } func systemStatus(cfg config.Config) SystemStatus { - terminal, _, _ := terminalCommand(cfg) + terminal, _, _ := terminalCommand(cfg, nil) return SystemStatus{ AURHelper: updater.AURHelper(), Terminal: terminal, @@ -298,10 +311,22 @@ func kernelVersion() string { 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 _" + 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 { - 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{} @@ -334,6 +359,37 @@ func terminalCommand(cfg config.Config) (string, []string, error) { 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 { switch runtime.GOOS { case "linux": diff --git a/internal/gui/server_test.go b/internal/gui/server_test.go new file mode 100644 index 0000000..aa9de97 --- /dev/null +++ b/internal/gui/server_test.go @@ -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) + } +}
|---|