diff --git a/arch-updater/README.md b/arch-updater/README.md index e47453e..9a365cd 100644 --- a/arch-updater/README.md +++ b/arch-updater/README.md @@ -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 ` 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. | diff --git a/arch-updater/panel.luau b/arch-updater/panel.luau index bdf70d6..befcc3d 100644 --- a/arch-updater/panel.luau +++ b/arch-updater/panel.luau @@ -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() diff --git a/arch-updater/plugin.toml b/arch-updater/plugin.toml index e372c13..c78fec1 100644 --- a/arch-updater/plugin.toml +++ b/arch-updater/plugin.toml @@ -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]] diff --git a/arch-updater/service.luau b/arch-updater/service.luau index 6c526d6..53064ba 100644 --- a/arch-updater/service.luau +++ b/arch-updater/service.luau @@ -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 = { ["<"] = "<", [">"] = ">", @@ -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() diff --git a/arch-updater/translations/de.json b/arch-updater/translations/de.json index 1a0d701..8a8df64 100644 --- a/arch-updater/translations/de.json +++ b/arch-updater/translations/de.json @@ -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" diff --git a/arch-updater/translations/en.json b/arch-updater/translations/en.json index d6eb48d..a3926f8 100644 --- a/arch-updater/translations/en.json +++ b/arch-updater/translations/en.json @@ -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" diff --git a/arch-updater/widget.luau b/arch-updater/widget.luau index a1a0302..017a1e0 100644 --- a/arch-updater/widget.luau +++ b/arch-updater/widget.luau @@ -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