fix(arch-updater): tighten package list UI and fix Dismiss/Update panel behavior (#289)

* fix(arch-updater): tighten package list spacing and icon size

* fix(arch-updater): make Dismiss actually clear the pending list

* fix(arch-updater): make hitting update close the panel

* feat(arch-updater): add per-source icons to the package list header

* feat(arch-updater): add an activity graph to the panel

Tracks the pending-update count across recent checks and when the last
update ran, persisted to disk so it survives restarts. Off also stops
recording history, not just hiding it.

* feat(arch-updater): show per-point detail on activity graph hover

ui.graph has no pointer props of its own, so a row of ghost buttons
sits under the line as per-point hit targets, each with a native
tooltip. History entries now carry a timestamp and whether the check
followed an update run, so hovering a point says when it happened and
either its pending count or "Updated".

* fix(arch-updater): load activity history at startup, not on first check

loadHistoryState() only ran lazily inside recordCheck/recordUpdateRun,
so a freshly started service published an empty history until a check
completed, hiding the graph even when prior sessions had data on disk.

* fix(arch-updater): render activity graph with sharp points, not curves
This commit is contained in:
Yuuto
2026-08-07 22:27:08 -04:00
committed by GitHub
parent 41a5373cd0
commit 4fcd5f1686
7 changed files with 398 additions and 18 deletions
+8 -2
View File
@@ -47,8 +47,8 @@ The panel groups pending packages by source (Pacman, AUR, Flatpak). Click a
source row to expand it into its packages. Each package row has a copy
button (name and versions) and an open button (its page on archlinux.org,
the AUR, or Flathub). **Check Updates** queries all sources, **Update** opens
a terminal running the upgrade, **Dismiss** keeps the numbers but returns the
bar glyph to its resting colour.
a terminal running the upgrade, **Dismiss** clears the pending list and
closes the panel, returning the bar glyph to its resting colour.
Type `/arch` in the launcher for quick actions (check, update, open news), or
`/arch <text>` to fuzzy-search the packages from the last check. Activating a
@@ -78,6 +78,10 @@ Not in the v4 plugin:
- **A generic package-page link** (`archlinux.org/packages`,
`aur.archlinux.org`, `flathub.org`) instead of hardcoded per-repo mirror
URLs, so it stays correct across Arch-based distros.
- **An activity graph.** A small trend line of the pending-update count
across the most recent checks, plus when you last ran an update. Persisted
to disk so it survives restarts. On by default, and turning it off also
stops recording the history, not just hiding it.
## Settings
@@ -92,6 +96,8 @@ Not in the v4 plugin:
| `show_download_size` | `bool` | `true` | Show the estimated pacman download size (`pacman -Si`) in the panel. |
| `check_arch_news` | `bool` | `true` | Check the Arch Linux news feed and flag unread posts. |
| `check_reboot_needed` | `bool` | `true` | Flag when the running kernel is no longer installed on disk. |
| `show_activity_graph` | `bool` | `true` | Track and show the pending-update trend and last-update time in the panel. Off also stops recording history. |
| `activity_history_length` | `int` | `10` | How many of the most recent checks to keep for the activity graph. |
| `terminal` | `string` | *(empty)* | Terminal command for the update run, e.g. `kitty`. Empty uses Noctalia's detection. |
| `assume_yes` | `bool` | `false` | Pass `--noconfirm` / `-y` so package managers do not ask for confirmation. |
| `update_cmd` | `string` | *(empty)* | Full override for the update command. Empty builds it from the settings above. |
+194 -5
View File
@@ -6,7 +6,7 @@
--
-- "Check Updates" only queries pacman, the AUR helper and (optionally)
-- Flatpak. Only then do "Update" (open a terminal and run the upgrade) and
-- "Dismiss" (keep the numbers, quiet the bar) light up.
-- "Dismiss" (clear the pending list and close the panel) light up.
local STATE_KEY = "arch_state"
local REQUEST_KEY = "arch_request"
@@ -15,6 +15,7 @@ local snapshot = nil
local expanded = {} -- source key -> the package list is open
local hoverKey = nil -- package row currently under the pointer
local hoverText = "" -- what the detail line shows
local activityHoverIndex = nil -- activity graph point currently under the pointer
local listOpen = false -- at least one source is expanded this render
local render
@@ -116,6 +117,12 @@ local function sourceLabel(key, entry)
return tr("source." .. key)
end
local SOURCE_GLYPHS = { pacman = "package", aur = "cloud", flatpak = "app-window" }
local function sourceGlyph(key)
return SOURCE_GLYPHS[key] or "package"
end
-- pacman, then the AUR helper, then Flatpak. Only sources with pending
-- packages, biggest first.
local function orderedSources()
@@ -175,13 +182,15 @@ local function packageRow(sourceKey, index, item)
}))
end
table.insert(children, ui.button({
glyph = "copy", variant = "ghost", controlSize = "sm", tooltip = tr("tip_copy"),
glyph = "copy", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12,
tooltip = tr("tip_copy"),
onClick = function()
noctalia.copyToClipboard(detailFor(item), "text/plain")
end,
}))
table.insert(children, ui.button({
glyph = "external-link", variant = "ghost", controlSize = "sm", tooltip = tr("tip_open_page"),
glyph = "external-link", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12,
tooltip = tr("tip_open_page"),
onClick = function()
openPackage(sourceKey, item.name)
end,
@@ -224,6 +233,7 @@ local function sourceRows()
end
table.insert(rows, ui.row(header, {
ui.glyph({ name = #names == 0 and "point" or (open and "chevron-down" or "chevron-right"), size = 12, color = "on_surface_variant" }),
ui.glyph({ name = sourceGlyph(source.key), size = 13, color = "on_surface_variant" }),
ui.label({ text = sourceLabel(source.key, source.entry), color = "on_surface", flexGrow = 1 }),
ui.label({ text = tostring(source.entry.n), color = "primary", fontWeight = "bold" }),
}))
@@ -289,12 +299,182 @@ local function extras()
return lines
end
-- Normalizes the pending-count history to 0..1 for ui.graph, relative to the
-- min/max in the window (not a fixed scale, since pending counts vary wildly
-- between systems). A flat window (min == max, e.g. every check so far found
-- the same count) centers the line at 0.5 instead of pinning it to the top
-- edge, where it would be indistinguishable from the box border.
local function activityValues(history)
local minN, maxN = tonumber(history[1].n) or 0, tonumber(history[1].n) or 0
for _, entry in ipairs(history) do
local n = tonumber(entry.n) or 0
if n < minN then
minN = n
end
if n > maxN then
maxN = n
end
end
local range = maxN - minN
local values = {}
for _, entry in ipairs(history) do
local n = tonumber(entry.n) or 0
table.insert(values, range > 0 and (n - minN) / range or 0.5)
end
return values
end
local ACTIVITY_GRAPH_SUBDIVISIONS = 8
local function upsampleLinear(values, subdivisions)
if #values < 2 or subdivisions <= 1 then
return values
end
local out = {}
for i = 1, #values - 1 do
local a, b = values[i], values[i + 1]
for s = 0, subdivisions - 1 do
table.insert(out, a + (b - a) * (s / subdivisions))
end
end
table.insert(out, values[#values])
return out
end
local function padGraphLookbehind(values)
if #values == 0 then
return values
end
local out = { values[1], values[1] }
for _, v in ipairs(values) do
table.insert(out, v)
end
return out
end
-- "2 hours ago", "3 days ago", etc. nil for entries migrated from the old
-- history format, which never recorded a timestamp.
local function relativeTime(at)
local t = tonumber(at)
if t == nil then
return nil
end
local diff = os.time() - t
if diff < 60 then
return tr("activity.just_now")
elseif diff < 3600 then
local m = math.floor(diff / 60)
return noctalia.trp("activity.minutes_ago", m, { count = m })
elseif diff < 86400 then
local h = math.floor(diff / 3600)
return noctalia.trp("activity.hours_ago", h, { count = h })
else
local d = math.floor(diff / 86400)
return noctalia.trp("activity.days_ago", d, { count = d })
end
end
-- "Updated · 2 hours ago" for the check that verified an update run, else
-- "3 pending updates · 2 hours ago". Shared by the hover tooltip and the
-- top-right caption so both describe a point the same way.
local function describeEntry(entry)
local n = tonumber(entry.n) or 0
local text = entry.afterUpdate == true and tr("activity.updated")
or noctalia.trp("activity.pending_at", n, { count = n })
local when = relativeTime(entry.at)
if when ~= nil then
text = text .. " · " .. when
end
return text
end
-- ui.graph takes no pointer props of its own (only row/column/box/image/button
-- do), so per-point hover is a row of hit targets placed right under the
-- line. Each is a ghost button so it can carry a native tooltip at the
-- pointer, not just a box for the hit test. It cannot highlight the point on
-- the line itself, only drive the tooltip and the caption above it.
--
-- Real points sit at (k-1)/(#history-1) of the width (see
-- padGraphLookbehind), i.e. #history-1 equal gaps, not #history equal slots
-- - so this builds one equal-width segment per gap rather than per entry,
-- ending each segment exactly on the point at its right edge. That leaves
-- entry 1 (at the left edge, with no gap before it) without its own hover
-- zone, but keeps every other spike landing right at the end of its segment
-- instead of drifting toward the start of an oversized one.
local function activityHoverRow(history)
local segments = {}
for i = 2, #history do
segments[i - 1] = ui.button({
key = "activity-hit-" .. i,
variant = "ghost",
flexGrow = 1,
height = 12,
tooltip = describeEntry(history[i]),
onHover = function(state)
if state == "true" then
activityHoverIndex = i
elseif activityHoverIndex == i then
activityHoverIndex = nil
else
return
end
render()
end,
})
end
return ui.row({ key = "activity-hits", gap = 1 }, segments)
end
-- A small trend graph of pending-update counts across recent checks, plus
-- when the last update ran (or, while hovering a point, that point's own
-- description). Off entirely when show_activity_graph is off, and hidden
-- until there is enough history to draw a line.
local function activitySection()
if snapshot == nil or noctalia.getConfig("show_activity_graph") ~= true then
return nil
end
local history = type(snapshot.history) == "table" and snapshot.history or {}
if #history < 2 then
return nil
end
local hoveredEntry = activityHoverIndex ~= nil and history[activityHoverIndex] or nil
local caption
if hoveredEntry ~= nil then
caption = describeEntry(hoveredEntry)
else
local lastUpdateAt = tonumber(snapshot.lastUpdateAt)
if lastUpdateAt == nil then
caption = tr("activity.never_updated")
else
local days = math.floor((os.time() - lastUpdateAt) / 86400)
caption = days <= 0 and tr("activity.updated_today")
or noctalia.trp("activity.updated_days_ago", days, { count = days })
end
end
return ui.column({ key = "activity", gap = 4 }, {
ui.row({ justify = "space_between", align = "center" }, {
ui.label({ text = tr("activity.title"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant" }),
ui.label({ text = caption, fontSize = 10, color = "on_surface_variant" }),
}),
ui.graph({
values = padGraphLookbehind(upsampleLinear(activityValues(history), ACTIVITY_GRAPH_SUBDIVISIONS)),
color = "primary",
fillOpacity = 0.15,
lineWidth = 2,
height = 36,
}),
activityHoverRow(history),
})
end
local function body()
local children = {}
local rows = sourceRows()
if #rows > 0 then
table.insert(children, ui.scroll({ key = "sources", flexGrow = 1, gap = 6 }, rows))
table.insert(children, ui.scroll({ key = "sources", flexGrow = 1, gap = 3 }, rows))
else
table.insert(children, ui.spacer({ key = "filler", flexGrow = 1 }))
end
@@ -323,6 +503,11 @@ local function body()
}))
end
local activity = activitySection()
if activity ~= nil then
table.insert(children, activity)
end
return children
end
@@ -389,9 +574,10 @@ render = function()
}),
ui.button({
key = "dismiss" .. (hasUpdates and "" or "-off"),
text = tr("action_dismiss"), variant = "ghost", enabled = hasUpdates and snapshot.dismissed ~= true,
text = tr("action_dismiss"), variant = "ghost", enabled = hasUpdates,
onClick = function()
request("dismiss")
panel.close()
end,
}),
ui.button({
@@ -400,6 +586,7 @@ render = function()
tooltip = tr("tip_update"),
onClick = function()
request("update")
panel.close()
end,
}),
}))
@@ -413,6 +600,7 @@ function onOpen(_context)
expanded = {}
hoverKey = nil
hoverText = ""
activityHoverIndex = nil
render()
end
@@ -424,6 +612,7 @@ noctalia.state.watch(STATE_KEY, function(value)
expanded = {}
hoverKey = nil
hoverText = ""
activityHoverIndex = nil
end
snapshot = value
render()
+20 -1
View File
@@ -1,6 +1,6 @@
id = "yuuto/arch-updater"
name = "Arch Updater"
version = "1.0.1"
version = "1.1.0"
plugin_api = 9
author = "yuuto"
license = "MIT"
@@ -86,6 +86,25 @@ label_key = "settings.check_reboot_needed.label"
description_key = "settings.check_reboot_needed.description"
default = true
# ── Activity ─────────────────────────────────────────────────────────────────
[[setting]]
key = "show_activity_graph"
type = "bool"
label_key = "settings.show_activity_graph.label"
description_key = "settings.show_activity_graph.description"
default = true
[[setting]]
key = "activity_history_length"
type = "int"
label_key = "settings.activity_history_length.label"
description_key = "settings.activity_history_length.description"
default = 10
min = 3
max = 30
visible_when = { key = "show_activity_graph", values = ["true"] }
# ── Update run ───────────────────────────────────────────────────────────────
[[setting]]
+106 -9
View File
@@ -4,8 +4,8 @@
--
-- state "arch_state" = { nonce, phase, step, total, pacman, aur,
-- flatpak, downloadSizeMiB, rebootRecommended,
-- newsUnread, newsLatestTitle, dismissed, err,
-- checkedAt, ignoredCount }
-- newsUnread, newsLatestTitle, err,
-- checkedAt, ignoredCount, history, lastUpdateAt }
-- requests "arch_request" = { nonce, action } -- check|update|dismiss
--
-- Checking runs pacman, then the AUR helper, then Flatpak, then (if pacman
@@ -20,6 +20,7 @@ local REQUEST_KEY = "arch_request"
local NEWS_FILE = "news_state.json"
local NEWS_URL = "https://archlinux.org/feeds/news/"
local NEWS_PAGE = "https://archlinux.org/news/"
local HISTORY_FILE = "history_state.json"
local CHECK_TIMEOUT_MS = 45000 -- pacman/AUR/flatpak checks: each may sync a mirror
local SIZE_TIMEOUT_MS = 20000 -- pacman -Si: local db, no mirror sync
@@ -40,7 +41,6 @@ local newsUnread = 0
local newsLatestTitle = nil
local newsItems = {}
local newsLastSeenGuid = nil
local dismissed = false
local errMsg = nil
local checkedAt = ""
local stateNonce = 0
@@ -55,6 +55,10 @@ local startupTicks = 0
local sinceNewsCheck = 0
local newsStateLoaded = false
local newsDirty = false
local history = {} -- { n, at, afterUpdate } per check, oldest first, trimmed to activity_history_length
local lastUpdateAt = nil -- os.time() of the last update run that finished
local historyStateLoaded = false
local checkIsPostUpdate = false -- next finished check followed an update run
local startCheck
local checkNews
@@ -197,10 +201,11 @@ local function publish()
rebootRecommended = rebootRecommended,
newsUnread = newsUnread,
newsLatestTitle = newsLatestTitle,
dismissed = dismissed,
err = errMsg,
checkedAt = checkedAt,
ignoredCount = #ignoreList(),
history = history,
lastUpdateAt = lastUpdateAt,
flatpakEnabled = cfg("flatpak_enabled") == true,
})
end
@@ -211,6 +216,7 @@ local checkFlatpak
local checkSize
local checkReboot
local finishCheck
local recordCheck
local function failCheck(message)
phase = "error"
@@ -423,9 +429,9 @@ finishCheck = function()
total = sources.pacman.n + sources.aur.n + sources.flatpak.n
step = ""
phase = total > 0 and "ready" or "clean"
dismissed = false
checkedAt = noctalia.formatTime("%H:%M")
sinceCheck = 0
recordCheck()
publish()
if total > 0 and cfg("notify_on_updates") == true then
noctalia.notify(tr("title"), noctalia.trp("notify_updates", total, { count = total }))
@@ -469,6 +475,92 @@ local function saveNewsState()
end
end
-- ── Activity history ─────────────────────────────────────────────────────────
local function historyStatePath()
local dir, err = noctalia.pluginDataDir()
if dir == nil then
noctalia.log("arch-updater: cannot resolve plugin data dir: " .. tostring(err))
return nil
end
return dir .. "/" .. HISTORY_FILE
end
local function loadHistoryState()
if historyStateLoaded then
return
end
historyStateLoaded = true
local path = historyStatePath()
local encoded = path ~= nil and noctalia.readFile(path) or nil
local ok, decoded = pcall(function()
return encoded ~= nil and noctalia.json.decode(encoded) or nil
end)
if ok and type(decoded) == "table" then
if type(decoded.history) == "table" then
-- Migrates the old format (a plain array of counts) to entries with
-- a timestamp and an afterUpdate flag, both unknown for old data.
local migrated = {}
for _, entry in ipairs(decoded.history) do
if type(entry) == "table" then
table.insert(migrated, {
n = tonumber(entry.n) or 0,
at = tonumber(entry.at),
afterUpdate = entry.afterUpdate == true,
})
elseif type(entry) == "number" then
table.insert(migrated, { n = entry, at = nil, afterUpdate = false })
end
end
history = migrated
end
if type(decoded.lastUpdateAt) == "number" then
lastUpdateAt = decoded.lastUpdateAt
end
end
end
local function saveHistoryState()
local path = historyStatePath()
if path == nil then
return
end
local encoded = noctalia.json.encode({ history = history, lastUpdateAt = lastUpdateAt })
if encoded ~= nil then
noctalia.writeFile(path, encoded)
end
end
-- Appends the current total to the activity history, trimmed to the
-- configured length. A no-op when the graph is turned off, so disabling it
-- also stops collecting data, not just hides it.
recordCheck = function()
local wasPostUpdate = checkIsPostUpdate
checkIsPostUpdate = false
if cfg("show_activity_graph") ~= true then
return
end
loadHistoryState()
table.insert(history, { n = total, at = os.time(), afterUpdate = wasPostUpdate })
local maxLen = math.max(3, math.min(30, tonumber(cfg("activity_history_length")) or 10))
while #history > maxLen do
table.remove(history, 1)
end
saveHistoryState()
end
-- Marks the check that follows as the one that verifies an update run, so its
-- history entry can say "Updated" instead of just a pending count.
local function recordUpdateRun()
checkIsPostUpdate = true
if cfg("show_activity_graph") ~= true then
return
end
loadHistoryState()
lastUpdateAt = os.time()
saveHistoryState()
end
local HTML_ENTITIES = {
["&lt;"] = "<",
["&gt;"] = ">",
@@ -665,6 +757,7 @@ local function pollRun()
return
end
if runSeen or runTicks >= RUN_GRACE_SECONDS then
recordUpdateRun()
startCheck()
end
end, updateProcessName)
@@ -681,10 +774,13 @@ local function handle(action)
elseif action == "update" then
runUpdate()
elseif action == "dismiss" then
if not dismissed then
dismissed = true
publish()
end
sources.pacman = { n = 0, items = {} }
sources.aur = { n = 0, items = {}, helper = sources.aur.helper }
sources.flatpak = { n = 0, items = {} }
total = 0
downloadSizeMiB = nil
phase = "clean"
publish()
elseif action == "open_news" then
openNews()
end
@@ -750,4 +846,5 @@ if not noctalia.commandExists("checkupdates") then
phase = "missing"
errMsg = tr("err_no_checkupdates")
end
loadHistoryState() -- so the graph shows prior sessions' data before the first check runs
publish()
+35
View File
@@ -3,6 +3,33 @@
"action_dismiss": "Verwerfen",
"action_open_news": "News öffnen",
"action_update": "Aktualisieren",
"activity": {
"days_ago": {
"one": "vor 1 Tag",
"other": "vor {count} Tagen"
},
"hours_ago": {
"one": "vor 1 Stunde",
"other": "vor {count} Stunden"
},
"just_now": "gerade eben",
"minutes_ago": {
"one": "vor 1 Minute",
"other": "vor {count} Minuten"
},
"never_updated": "Noch nie aktualisiert",
"pending_at": {
"one": "1 ausstehendes Update",
"other": "{count} ausstehende Updates"
},
"title": "Aktivität",
"updated": "Aktualisiert",
"updated_days_ago": {
"one": "Vor 1 Tag aktualisiert",
"other": "Vor {count} Tagen aktualisiert"
},
"updated_today": "Heute aktualisiert"
},
"caption_checked": "geprüft {time}",
"caption_ignored": {
"one": "1 Paket ignoriert",
@@ -33,6 +60,10 @@
"press_key": "Beliebige Taste zum Schließen drücken"
},
"settings": {
"activity_history_length": {
"description": "Wie viele der letzten Prüfungen für das Aktivitätsdiagramm aufbewahrt werden.",
"label": "Länge des Aktivitätsverlaufs"
},
"assume_yes": {
"description": "Übergibt --noconfirm / -y, damit Paketmanager nicht nach Bestätigung fragen. Aus bedeutet, du bestätigst jedes Paket im Terminal-Fenster selbst.",
"label": "Automatisch mit Ja bestätigen"
@@ -84,6 +115,10 @@
"description": "Sendet eine Desktop-Benachrichtigung, wenn eine Prüfung aktualisierbare Pakete findet.",
"label": "Bei gefundenen Updates benachrichtigen"
},
"show_activity_graph": {
"description": "Zeichnet die Anzahl ausstehender Updates über die letzten Prüfungen sowie den letzten Update-Zeitpunkt auf und zeigt sie als kleines Diagramm im Panel. Deaktiviert stoppt auch die Aufzeichnung.",
"label": "Aktivitätsdiagramm anzeigen"
},
"show_count": {
"description": "Zeigt die Anzahl ausstehender Updates neben dem Bar-Symbol.",
"label": "Anzahl der Updates anzeigen"
+35
View File
@@ -3,6 +3,33 @@
"action_dismiss": "Dismiss",
"action_open_news": "Open news",
"action_update": "Update",
"activity": {
"days_ago": {
"one": "1 day ago",
"other": "{count} days ago"
},
"hours_ago": {
"one": "1 hour ago",
"other": "{count} hours ago"
},
"just_now": "just now",
"minutes_ago": {
"one": "1 minute ago",
"other": "{count} minutes ago"
},
"never_updated": "Never updated",
"pending_at": {
"one": "1 pending update",
"other": "{count} pending updates"
},
"title": "Activity",
"updated": "Updated",
"updated_days_ago": {
"one": "Updated 1 day ago",
"other": "Updated {count} days ago"
},
"updated_today": "Updated today"
},
"caption_checked": "checked {time}",
"caption_ignored": {
"one": "1 package ignored",
@@ -36,6 +63,10 @@
"press_key": "Press any key to close"
},
"settings": {
"activity_history_length": {
"description": "How many of the most recent checks to keep for the activity graph.",
"label": "Activity history length"
},
"assume_yes": {
"description": "Pass --noconfirm / -y so package managers do not ask for confirmation. Off means you confirm each one in the terminal window.",
"label": "Answer yes automatically"
@@ -87,6 +118,10 @@
"description": "Send a desktop notification when a check finds packages to upgrade.",
"label": "Notify when updates are found"
},
"show_activity_graph": {
"description": "Track pending-update counts across recent checks and when you last updated, shown as a small graph in the panel. Turning this off also stops recording the history.",
"label": "Show activity graph"
},
"show_count": {
"description": "Show the number of pending updates next to the bar glyph.",
"label": "Show the update count"
-1
View File
@@ -32,7 +32,6 @@ end
local function pending()
return snapshot ~= nil
and snapshot.phase == "ready"
and snapshot.dismissed ~= true
and (snapshot.total or 0) > 0
end