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
+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()