feat: add selected update installs and polished UI
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -17,7 +17,8 @@ const indexHTML = `<!doctype html>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -57,11 +58,19 @@ const indexHTML = `<!doctype html>
|
||||
</select>
|
||||
</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="emptyState" class="empty">Keine passenden Updates gefunden.</div>
|
||||
<table id="updatesTable" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Quelle</th>
|
||||
<th>Paket</th>
|
||||
<th>Aktuell</th>
|
||||
@@ -161,14 +170,16 @@ const indexHTML = `<!doctype html>
|
||||
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...";
|
||||
|
||||
@@ -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,11 +311,23 @@ 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 {
|
||||
if len(packages) == 0 {
|
||||
updateCommand = helper + " -Syu; printf '\\nDone. Press enter to close... '; read _"
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if cfg.Terminal != "" && cfg.Terminal != "auto" {
|
||||
@@ -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":
|
||||
|
||||
11
internal/gui/server_test.go
Normal file
11
internal/gui/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user