Files
community-plugins/topgrade-wrapper/panel.luau
T

466 lines
17 KiB
Luau

--!nonstrict
-- topgrade-wrapper — update panel. A pure renderer over the shared state: the
-- engine (service.luau) publishes "topgrade_state" and performs the
-- "topgrade_request" actions this panel emits, so closing the panel never
-- interrupts a check or a run in progress.
--
-- The flow is deliberately two-step. "Check Updates" only queries, and its
-- result is a per-manager breakdown plus, when something could not be counted,
-- the names of the steps it left out. Only then do "Update" (open a terminal
-- and run topgrade) and "Dismiss" (keep the numbers, quiet the bar) light up.
local STATE_KEY = "topgrade_state"
local REQUEST_KEY = "topgrade_request"
-- Read out of the plugin's own manifest (readFile resolves a relative path
-- against the plugin directory), so the header cannot drift from the version
-- the store shows. Empty when unreadable — a missing version is not worth an
-- error line in the panel.
local pluginVersion = (function()
local text = noctalia.readFile("plugin.toml")
if type(text) ~= "string" then
return ""
end
return ("\n" .. text):match('\nversion%s*=%s*"([^"]+)"') or ""
end)()
local snapshot = nil
local expanded = {} -- manager key -> the package list is open
local hoverKey = nil -- package row currently under the pointer
local hoverText = "" -- what the detail line shows
local listOpen = false -- at least one manager is expanded this render
local render
local function tr(key, args)
return noctalia.tr(key, args)
end
-- The nonce is monotonic across writers (the panel and every widget instance):
-- each seeds from the last request already in the shared state.
local function request(action)
local prev = noctalia.state.get(REQUEST_KEY)
local nonce = (type(prev) == "table" and tonumber(prev.nonce) or 0) + 1
noctalia.state.set(REQUEST_KEY, { nonce = nonce, action = action })
end
local function versionsShown()
return noctalia.getConfig("show_versions") ~= false
end
-- The full text for the detail line: everything the row may have had to elide.
local function detailFor(item)
local from = item.from ~= nil and item.from or ""
local to = item.to ~= nil and item.to or ""
if to == "" then
return item.name
end
if from == "" then
return item.name .. " → " .. to
end
return item.name .. " " .. from .. " → " .. to
end
local function phaseOf()
return snapshot ~= nil and snapshot.phase or "idle"
end
local function totalOf()
return snapshot ~= nil and tonumber(snapshot.total) or 0
end
local function busy()
local phase = phaseOf()
return phase == "checking" or phase == "running"
end
local function headline()
local phase = phaseOf()
if phase == "missing" then
return tr("status_missing"), "error"
elseif phase == "error" then
return (snapshot ~= nil and snapshot.err) or tr("status_error"), "error"
elseif phase == "checking" then
local step = snapshot.step
if step ~= nil and step ~= "" then
return tr("status_checking_step", { step = step }), "secondary"
end
return tr("status_checking"), "secondary"
elseif phase == "running" then
return tr("status_running"), "secondary"
elseif phase == "clean" then
return tr("status_clean"), "on_surface"
elseif phase == "ready" then
return noctalia.trp("status_ready", totalOf(), {}), "primary"
end
return tr("status_idle"), "on_surface_variant"
end
-- Version pair width. Arch git-snapshot versions run past 25 characters, so the
-- pair is capped and elided here and the hover line below the list carries the
-- full text: no plugin UI primitive can put a tooltip on a plain row.
local VERSION_WIDTH = 82
-- A single package inside an expanded manager: name, then — unless the versions
-- are switched off — `installed → available` with the incoming version in the
-- accent colour so the eye lands on what changes.
--
-- The hover callback is a closure over this row's own package, which is why it
-- needs no lookup table and no key argument: it already holds the text it will
-- show. It fills the detail line below the list, the only place a long version
-- pair fits unelided (a tooltip is not an option — the UI offers that on
-- ui.button alone), and every row feeds it whether or not it shows the pair.
local function packageRow(managerKey, index, item)
local key = "pkg-" .. managerKey .. "-" .. index
local children = {
ui.label({ text = item.name, fontSize = 11, color = "on_surface", flexGrow = 1, maxLines = 1 }),
}
-- Versions off narrows the row to the name, but the hover line below the list
-- keeps working exactly as it does with them on: the setting is about how
-- much every row carries at rest, not about giving up the detail.
local from = item.from ~= nil and item.from or ""
local to = item.to ~= nil and item.to or ""
if versionsShown() and to ~= "" then
if from ~= "" then
table.insert(children, ui.label({
text = from,
fontSize = 11,
color = "on_surface_variant",
maxWidth = VERSION_WIDTH,
maxLines = 1,
}))
end
table.insert(children, ui.label({ text = "→", fontSize = 11, color = "on_surface_variant" }))
table.insert(children, ui.label({
text = to,
fontSize = 11,
color = "primary",
fontWeight = "semibold",
maxWidth = VERSION_WIDTH,
maxLines = 1,
}))
end
-- Only the leave of the row that is still the hovered one clears the line, so
-- an enter that beats its predecessor's leave is not undone by it.
return ui.row({
key = key,
paddingH = 18,
gap = 4,
align = "center",
onHover = function(state)
if state == "true" then
hoverKey = key
hoverText = detailFor(item)
elseif hoverKey == key then
hoverKey = nil
hoverText = ""
else
return
end
render()
end,
}, children)
end
-- One row per manager that answered, biggest first so the row that matters is
-- at the top. Managers that answered zero are kept out of the list and summed
-- up in the footer caption instead.
--
-- A row with names behind it is a click target that expands into them. The
-- expanded set is panel-local on purpose: which rows you opened is a property of
-- looking at the list, not of the check, so it is neither published nor kept
-- across a re-check.
local function countRows()
local rows = {}
if snapshot == nil or type(snapshot.counts) ~= "table" then
return rows
end
local pending = {}
for _, entry in ipairs(snapshot.counts) do
if (tonumber(entry.n) or 0) > 0 then
table.insert(pending, entry)
end
end
table.sort(pending, function(a, b)
if a.n == b.n then
return tostring(a.key) < tostring(b.key)
end
return a.n > b.n
end)
for _, entry in ipairs(pending) do
local names = type(entry.items) == "table" and entry.items or {}
local open = expanded[entry.key] == true and #names > 0
listOpen = listOpen or open
local header = { gap = 8, align = "center", key = "count-" .. entry.key .. (open and "-open" or "") }
if #names > 0 then
local managerKey = entry.key
header.onClick = function()
expanded[managerKey] = not expanded[managerKey]
-- Folding removes the rows the detail line was describing.
hoverKey = nil
hoverText = ""
render()
end
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.label({ text = tr("count." .. entry.key), color = "on_surface", flexGrow = 1 }),
ui.label({ text = tostring(entry.n), color = "primary", fontWeight = "bold" }),
}))
if open then
for index, item in ipairs(names) do
table.insert(rows, packageRow(entry.key, index, item))
end
-- The engine caps the stored list; say so rather than let the rows
-- silently disagree with the count beside the manager.
if entry.n > #names then
table.insert(rows, ui.row({ key = "more-" .. entry.key, paddingH = 18 }, {
ui.label({
text = tr("more_packages", { count = entry.n - #names }),
fontSize = 11,
color = "on_surface_variant",
}),
}))
end
end
end
return rows
end
-- Managers that answered "0" — worth showing, because "checked and up to date"
-- and "never checked" must not look the same.
local function cleanNames()
local names = {}
if snapshot == nil or type(snapshot.counts) ~= "table" then
return names
end
for _, entry in ipairs(snapshot.counts) do
if (tonumber(entry.n) or 0) == 0 then
table.insert(names, tr("count." .. entry.key))
end
end
return names
end
local function body()
local phase = phaseOf()
local children = {}
if phase == "checking" then
table.insert(children, ui.progress({ key = "check-progress", progress = tonumber(snapshot.progress) or 0 }))
end
local rows = countRows()
if #rows > 0 then
table.insert(children, ui.scroll({ key = "counts", flexGrow = 1, gap = 6 }, rows))
else
table.insert(children, ui.spacer({ key = "filler", flexGrow = 1 }))
end
-- The detail line is rendered for as long as any list is open, hovered or
-- not: a line that appeared on hover would resize the list under the pointer
-- and flicker the row straight back out from under it.
if listOpen then
table.insert(children, ui.label({
key = "hover-detail",
text = hoverText ~= "" and hoverText or tr("hover_hint"),
fontSize = 11,
color = hoverText ~= "" and "on_surface" or "on_surface_variant",
maxLines = 1,
}))
end
-- Everything topgrade would run but nothing could count: listed by step
-- name, never folded into the total.
if snapshot ~= nil and type(snapshot.uncounted) == "table" and #snapshot.uncounted > 0 then
table.insert(children, ui.label({
text = tr("uncounted", { steps = table.concat(snapshot.uncounted, ", ") }),
fontSize = 11,
color = "on_surface_variant",
maxLines = 3,
}))
end
local clean = cleanNames()
if #clean > 0 then
table.insert(children, ui.label({
text = tr("up_to_date", { steps = table.concat(clean, ", ") }),
fontSize = 11,
color = "on_surface_variant",
maxLines = 2,
}))
end
return children
end
-- Shortens $HOME to ~ for display. The prefix is pattern-escaped: a home
-- directory holding a dash or a dot would otherwise be read as a pattern.
local function tildify(path)
local home = noctalia.getenv("HOME")
if home == nil or home == "" then
return path
end
local escaped = home:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
return (path:gsub("^" .. escaped, "~"))
end
-- Config file and exclusion count: the two things that decide what the number
-- above actually covers.
local function configCaption()
if snapshot == nil then
return nil
end
local parts = {}
local path = snapshot.configPath
if path ~= nil and path ~= "" then
table.insert(parts, tr("caption_config", { path = tildify(path) }))
else
table.insert(parts, tr("caption_config_default"))
end
local excluded = tonumber(snapshot.excluded) or 0
if excluded > 0 then
table.insert(parts, noctalia.trp("caption_excluded", excluded, {}))
end
if snapshot.checkedAt ~= nil and snapshot.checkedAt ~= "" then
table.insert(parts, tr("caption_checked", { time = snapshot.checkedAt }))
end
return table.concat(parts, " · ")
end
render = function()
-- Recomputed from the tree that is about to be produced.
listOpen = false
local text, color = headline()
local phase = phaseOf()
local hasUpdates = totalOf() > 0 and (phase == "ready" or phase == "running")
local children = {
-- Every child of the header carries a stable key, so the reconciler
-- matches roles instead of guessing by position and type.
ui.row({ key = "header", gap = 8, align = "center" }, {
ui.label({
key = "title",
text = tr("title"),
fontSize = 16,
fontWeight = "bold",
color = "on_surface",
}),
-- Version off the manifest, small and dimmed: it answers "which
-- build am I running" without competing with the title. The spacer
-- rather than a flexGrow title keeps the two together on the left.
ui.label({ key = "version", text = pluginVersion, fontSize = 10, color = "on_surface_variant" }),
ui.spacer({ key = "gap", flexGrow = 1 }),
ui.button({
key = "header-check" .. (busy() and "-off" or ""),
glyph = "refresh",
variant = "ghost",
enabled = not busy() and phase ~= "missing",
tooltip = tr("tip_check"),
onClick = function()
request("check")
end,
}),
-- Opens the settings window on this plugin's own page (the host
-- supplies the plugin id, so a plugin can only ever open its own).
-- The panel closes on the way; the engine keeps running.
ui.button({
key = "settings",
glyph = "settings",
variant = "ghost",
tooltip = tr("tip_settings"),
onClick = function()
noctalia.openSettings()
end,
}),
ui.button({
key = "close",
glyph = "close",
variant = "ghost",
tooltip = tr("tip_close"),
onClick = function()
panel.close()
end,
}),
}),
ui.label({ text = text, color = color, maxLines = 2 }),
}
for _, node in ipairs(body()) do
table.insert(children, node)
end
local caption = configCaption()
if caption ~= nil then
table.insert(children, ui.separator({}))
table.insert(children, ui.label({ text = caption, fontSize = 11, color = "on_surface_variant", maxLines = 3 }))
end
if phase ~= "missing" then
table.insert(children, ui.row({ gap = 8, align = "center" }, {
ui.button({
key = "check" .. (busy() and "-off" or ""),
glyph = "refresh",
text = tr("action_check"),
variant = "ghost",
enabled = not busy(),
flexGrow = 1,
onClick = function()
request("check")
end,
}),
ui.button({
key = "dismiss" .. (hasUpdates and "" or "-off"),
text = tr("action_dismiss"),
variant = "ghost",
enabled = hasUpdates and snapshot.dismissed ~= true,
onClick = function()
request("dismiss")
end,
}),
ui.button({
key = "update" .. (hasUpdates and not busy() and "" or "-off"),
glyph = "download",
text = tr("action_update"),
variant = "primary",
enabled = hasUpdates and not busy(),
tooltip = tr("tip_update"),
onClick = function()
request("update")
end,
}),
}))
end
panel.render(ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, children))
end
function onOpen(_context)
snapshot = noctalia.state.get(STATE_KEY)
expanded = {}
hoverKey = nil
hoverText = ""
render()
end
noctalia.state.watch(STATE_KEY, function(value)
if type(value) ~= "table" then
return
end
-- A new check invalidates the rows the old one produced, so start it folded.
if value.phase == "checking" and (snapshot == nil or snapshot.phase ~= "checking") then
expanded = {}
hoverKey = nil
hoverText = ""
end
snapshot = value
render()
end)