Files
fedora-dots/.local/state/noctalia/plugins/materialized/community/dnf-updater/panel.luau
T
2026-11-04 20:35:46 +01:00

1010 lines
28 KiB
Luau

--!nonstrict
-- Panel entry: declarative pop-up panel for DNF Updater (DNF5 + Flatpak).
-- Renders summary cards, categorized update lists, storage metrics, and execution triggers.
local enableDnf = noctalia.getConfig("enable_dnf") ~= false
local enableFlatpak = noctalia.getConfig("enable_flatpak") ~= false
local function getPluginVersion()
local text = noctalia.readFile("plugin.toml")
if type(text) ~= "string" then
return ""
end
return ("\n" .. text):match('\nversion%s*=%s*"([^"]+)"') or ""
end
local updateState = noctalia.state.get("update_state")
local excludedDnf = {}
local excludedFlatpak = {}
-- Native-arch packages are the norm; only an odd one (noarch, i686) is worth a badge.
local machineArch = (noctalia.readFile("/proc/sys/kernel/arch") or ""):match("%S+") or ""
-- Fedora's own repos: their name tells nothing ("updates"), only third-party origins are worth showing.
local officialRepos = { fedora = true, updates = true, ["updates-testing"] = true, ["updates-archive"] = true, ["fedora-cisco-openh264"] = true }
local dnfCollapsed = false
local flatpakCollapsed = false
local searchQuery = ""
local render -- forward declaration
local function formatBytes(bytes)
if not bytes or bytes <= 0 then
return "0 B"
end
local units = { "B", "KiB", "MiB", "GiB", "TiB" }
local b = tonumber(bytes) or 0
local uIndex = 1
while b >= 1024 and uIndex < #units do
b = b / 1024
uIndex = uIndex + 1
end
if uIndex == 1 then
return string.format("%d %s", b, units[uIndex])
else
return string.format("%.1f %s", b, units[uIndex])
end
end
local function parseSizeToBytes(sizeStr)
if not sizeStr or type(sizeStr) ~= "string" or sizeStr == "" then
return 0
end
local s = sizeStr:gsub(",", ".")
local numStr, unit = s:match("([%d%.]+).-([%a]+)")
if not numStr or not unit then
return 0
end
local num = tonumber(numStr)
if not num or num <= 0 then
return 0
end
unit = string.upper(unit)
if unit == "B" or unit == "BYTES" then
return math.floor(num)
elseif unit == "KIB" then
return math.floor(num * 1024)
elseif unit == "KB" or unit == "K" then
return math.floor(num * 1000)
elseif unit == "MIB" then
return math.floor(num * 1024 * 1024)
elseif unit == "MB" or unit == "M" then
return math.floor(num * 1000 * 1000)
elseif unit == "GIB" then
return math.floor(num * 1024 * 1024 * 1024)
elseif unit == "GB" or unit == "G" then
return math.floor(num * 1000 * 1000 * 1000)
elseif unit == "TIB" then
return math.floor(num * 1024 * 1024 * 1024 * 1024)
elseif unit == "TB" or unit == "T" then
return math.floor(num * 1000 * 1000 * 1000 * 1000)
end
return math.floor(num)
end
local function getPackageDownloadBytes(pkg)
if not pkg then return 0 end
if pkg.download_bytes and pkg.download_bytes > 0 then
return pkg.download_bytes
end
if pkg.download_formatted and pkg.download_formatted ~= "" then
local b = parseSizeToBytes(pkg.download_formatted)
if b > 0 then return b end
end
if pkg.install_bytes and pkg.install_bytes > 0 then
return pkg.install_bytes
end
if pkg.install_formatted and pkg.install_formatted ~= "" then
local b = parseSizeToBytes(pkg.install_formatted)
if b > 0 then return b end
end
return 0
end
local function getFlatpakKey(pkg)
if not pkg then return "" end
if pkg.ref and pkg.ref ~= "" then return pkg.ref end
if pkg.id and pkg.id ~= "" then return pkg.id end
return pkg.name or ""
end
local function toggleExcludeDnf(name)
if excludedDnf[name] then
excludedDnf[name] = nil
else
excludedDnf[name] = true
end
if render then
render()
end
end
local function toggleExcludeFlatpak(id)
if excludedFlatpak[id] then
excludedFlatpak[id] = nil
else
excludedFlatpak[id] = true
end
if render then
render()
end
end
local function toggleAllDnf(packages)
local anyExcluded = false
for _, pkg in ipairs(packages) do
if excludedDnf[pkg.name] then
anyExcluded = true
break
end
end
if anyExcluded then
for _, pkg in ipairs(packages) do
excludedDnf[pkg.name] = nil
end
else
for _, pkg in ipairs(packages) do
excludedDnf[pkg.name] = true
end
end
if render then
render()
end
end
local function toggleAllFlatpak(packages)
local anyExcluded = false
for _, pkg in ipairs(packages) do
local k = getFlatpakKey(pkg)
if excludedFlatpak[k] then
anyExcluded = true
break
end
end
if anyExcluded then
for _, pkg in ipairs(packages) do
excludedFlatpak[getFlatpakKey(pkg)] = nil
end
else
for _, pkg in ipairs(packages) do
excludedFlatpak[getFlatpakKey(pkg)] = true
end
end
if render then
render()
end
end
local function tr(key, params)
local text = noctalia.tr(key) or key
if params and type(params) == "table" then
for k, v in pairs(params) do
text = text:gsub("{" .. k .. "}", tostring(v))
end
end
return text
end
local function formatAgo(timestamp)
local minutes = math.floor((os.time() - timestamp) / 60)
if minutes < 1 then
return tr("ago.now")
elseif minutes < 60 then
return tr("ago.minutes", { n = minutes })
elseif minutes < 1440 then
return tr("ago.hours", { n = math.floor(minutes / 60) })
end
return tr("ago.days", { n = math.floor(minutes / 1440) })
end
local function formatLastChecked(state)
if state and (state.last_check_timestamp or 0) > 0 then
return tr("status.last_checked", { time = formatAgo(state.last_check_timestamp) })
end
return tr("status.never_checked")
end
local function statusCard(state)
local status = state and state.status or "IDLE"
local total = state and state.total_count or 0
if status == "CHECKING" then
return ui.row({
key = "status-card-checking",
padding = 12,
paddingH = 14,
fill = "surface",
radius = 8,
align = "center",
gap = 10,
}, {
ui.glyph({ name = "refresh", size = 18, color = "primary" }),
ui.label({
text = tr("status.checking"),
fontSize = 13,
fontWeight = "semibold",
color = "on_surface",
flexGrow = 1,
}),
})
elseif status == "UPDATING" then
local dnfStatus = state.dnf and state.dnf.status or "idle"
local flatpakStatus = state.flatpak and state.flatpak.status or "idle"
local dnfText = tr("status.dnf_" .. dnfStatus)
local flatpakText = tr("status.flatpak_" .. flatpakStatus)
return ui.column({
key = "status-card-updating",
padding = 12,
paddingH = 14,
fill = "surface",
radius = 8,
gap = 6,
}, {
ui.row({ align = "center", gap = 10 }, {
ui.glyph({ name = "refresh", size = 18, color = "primary" }),
ui.label({
text = tr("status.updating"),
fontSize = 13,
fontWeight = "semibold",
color = "on_surface",
flexGrow = 1,
}),
}),
ui.row({ align = "center", gap = 16, paddingH = 28 }, {
ui.label({ text = dnfText, fontSize = 11, color = "on_surface_variant" }),
ui.label({ text = flatpakText, fontSize = 11, color = "on_surface_variant" }),
}),
})
elseif status == "ERROR" and total == 0 then
local errMsg = (state and state.error_message) or tr("status.dnf_error")
return ui.row({
key = "status-card-error",
padding = 12,
paddingH = 14,
fill = "surface",
radius = 8,
align = "center",
gap = 10,
}, {
ui.glyph({ name = "alert-circle", size = 18, color = "error" }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({
text = errMsg,
fontSize = 12,
color = "error",
maxLines = 2,
}),
}),
})
elseif total == 0 then
-- Clean state: everything up to date
return ui.column({
key = "status-card-clean",
padding = 20,
paddingH = 16,
fill = "surface",
radius = 8,
align = "center",
justify = "center",
gap = 8,
}, {
ui.glyph({ name = "check", size = 28, color = "secondary" }),
ui.label({
text = tr("status.everything_up_to_date"),
fontSize = 14,
fontWeight = "semibold",
color = "on_surface",
}),
})
else
-- Updates available: overview metrics (DNF, Flatpak, Total only)
local dnfCount = state and state.dnf and state.dnf.count or 0
local flatpakCount = state and state.flatpak and state.flatpak.count or 0
return ui.row({
key = "status-card-summary",
padding = 8,
paddingH = 10,
fill = "surface",
radius = 8,
align = "center",
gap = 8,
}, {
ui.row({
key = "summary-dnf-chip",
flexGrow = 1,
padding = 6,
paddingH = 10,
fill = "surface_container",
radius = 6,
align = "center",
justify = "space_between",
}, {
ui.label({ text = tr("summary.dnf"), fontSize = 11, color = "on_surface_variant" }),
ui.label({ text = tostring(dnfCount), fontSize = 12, fontWeight = "bold", color = "primary" }),
}),
ui.row({
key = "summary-flatpak-chip",
flexGrow = 1,
padding = 6,
paddingH = 10,
fill = "surface_container",
radius = 6,
align = "center",
justify = "space_between",
}, {
ui.label({ text = tr("summary.flatpak"), fontSize = 11, color = "on_surface_variant" }),
ui.label({ text = tostring(flatpakCount), fontSize = 12, fontWeight = "bold", color = "secondary" }),
}),
ui.row({
key = "summary-total-chip",
flexGrow = 1,
padding = 6,
paddingH = 10,
fill = "surface_container",
radius = 6,
align = "center",
justify = "space_between",
}, {
ui.label({ text = tr("summary.total"), fontSize = 11, color = "on_surface_variant" }),
ui.label({ text = tostring(total), fontSize = 12, fontWeight = "bold", color = "on_surface" }),
}),
})
end
end
local function renderDnfSection(state)
local dnf = state and state.dnf
local packages = (dnf and dnf.packages) or {}
local isBusy = state and (state.status == "CHECKING" or state.status == "UPDATING")
if not enableDnf then
return nil, 0, 0
end
local selectedCount = 0
local selectedBytes = 0
for _, pkg in ipairs(packages) do
if not excludedDnf[pkg.name] then
selectedCount += 1
selectedBytes += getPackageDownloadBytes(pkg)
end
end
local dnfTitle
if #packages == 0 then
dnfTitle = "DNF"
elseif selectedCount == #packages then
dnfTitle = tr("sections.dnf", { count = #packages })
else
dnfTitle = tr("sections.dnf_selected", { selected = selectedCount, total = #packages })
end
if selectedBytes > 0 then
dnfTitle = dnfTitle .. " (" .. formatBytes(selectedBytes) .. ")"
end
local allSelected = #packages > 0 and selectedCount == #packages
local headerChildren = {
ui.button({
key = "btn-collapse-dnf",
glyph = dnfCollapsed and "chevron-right" or "chevron-down",
variant = "ghost",
controlSize = "sm",
tooltip = dnfCollapsed and tr("actions.expand") or tr("actions.collapse"),
onClick = function()
dnfCollapsed = not dnfCollapsed
render()
end,
}),
ui.label({
text = dnfTitle,
fontSize = 12,
fontWeight = "bold",
color = "on_surface",
flexGrow = 1,
}),
}
if #packages > 0 then
table.insert(headerChildren, ui.button({
key = "btn-toggle-all-dnf",
glyph = allSelected and "square-check" or "square",
variant = "ghost",
controlSize = "sm",
tooltip = allSelected and tr("actions.deselect_all") or tr("actions.select_all"),
enabled = not isBusy,
onClick = function()
toggleAllDnf(packages)
end,
}))
end
local items = {}
local header = ui.row({ key = "dnf-section-header", align = "center", gap = 8, paddingV = 4 }, headerChildren)
table.insert(items, header)
local q = searchQuery:lower():match("^%s*(.-)%s*$") or ""
local visibleCount = 0
if not dnfCollapsed then
for idx, pkg in ipairs(packages) do
local isMatch = (q == "")
or (pkg.name and pkg.name:lower():find(q, 1, true) ~= nil)
or (pkg.repo and pkg.repo:lower():find(q, 1, true) ~= nil)
if isMatch then
visibleCount += 1
local isExcluded = excludedDnf[pkg.name] == true
local arch = (pkg.arch ~= machineArch) and (pkg.arch or "") or ""
local repo = ""
if pkg.repo and pkg.repo ~= "" and not officialRepos[pkg.repo] then
-- COPR repo ids ("copr:copr.fedorainfracloud.org:codifryed:CoolerControl") pushed the row wider than the panel.
repo = pkg.repo:match("^copr%a*:[^:]+:[^:]+:(.+)$") or pkg.repo
end
local badgeColor = isExcluded and "on_surface_variant" or "primary"
local badgeChildren = {}
if arch ~= "" then
table.insert(badgeChildren, ui.label({
text = repo ~= "" and (arch .. " ·") or arch,
fontSize = 10,
color = badgeColor .. "/0.6",
fontWeight = "semibold",
}))
end
if repo ~= "" then
table.insert(badgeChildren, ui.label({
text = repo,
maxWidth = 160,
maxLines = 1,
fontSize = 10,
color = badgeColor,
fontWeight = "semibold",
}))
end
local sizeLabel = (pkg.download_formatted and pkg.download_formatted ~= "") and pkg.download_formatted or ""
local nameRowChildren = {
ui.label({
text = pkg.name or "Unknown",
fontSize = 12,
fontWeight = "semibold",
color = isExcluded and "on_surface_variant" or "on_surface",
}),
}
if isExcluded then
table.insert(nameRowChildren, ui.label({
text = "(" .. tr("status.skipped") .. ")",
fontSize = 10,
color = "on_surface_variant",
}))
end
table.insert(items, ui.row({
key = "dnf-pkg-" .. tostring(idx) .. "-" .. (pkg.name or "pkg"),
padding = 8,
paddingH = 12,
fill = isExcluded and "surface_container" or "surface",
radius = 6,
align = "center",
gap = 8,
onClick = function()
if not isBusy then
toggleExcludeDnf(pkg.name)
end
end,
}, {
ui.glyph({
name = isExcluded and "square" or "square-check",
size = 18,
color = isExcluded and "on_surface_variant" or "primary",
}),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.row({ align = "center", gap = 6 }, nameRowChildren),
ui.label({
text = pkg.evr or "",
fontSize = 11,
color = "on_surface_variant",
}),
}),
ui.column({ align = "end", gap = 2 }, {
ui.label({
text = sizeLabel,
fontSize = 11,
fontWeight = "bold",
color = isExcluded and "on_surface_variant" or "on_surface",
}),
ui.row({ align = "center", gap = 4 }, badgeChildren),
}),
}))
end
end
if q ~= "" and visibleCount == 0 and #packages > 0 then
table.insert(items, ui.label({
key = "dnf-no-match",
text = tr("search.no_matches", { query = searchQuery }),
fontSize = 11,
color = "on_surface_variant",
padding = 8,
}))
end
end
return items, selectedCount, selectedBytes
end
local function renderFlatpakSection(state)
local flatpak = state and state.flatpak
local packages = (flatpak and flatpak.packages) or {}
local isBusy = state and (state.status == "CHECKING" or state.status == "UPDATING")
if not enableFlatpak then
return nil, 0, 0
end
local selectedCount = 0
local selectedBytes = 0
for _, pkg in ipairs(packages) do
local k = getFlatpakKey(pkg)
if not excludedFlatpak[k] then
selectedCount += 1
selectedBytes += getPackageDownloadBytes(pkg)
end
end
local flatpakTitle
if #packages == 0 then
flatpakTitle = "Flatpak"
elseif selectedCount == #packages then
flatpakTitle = tr("sections.flatpak", { count = #packages })
else
flatpakTitle = tr("sections.flatpak_selected", { selected = selectedCount, total = #packages })
end
if selectedBytes > 0 then
flatpakTitle = flatpakTitle .. " (" .. formatBytes(selectedBytes) .. ")"
end
local allSelected = #packages > 0 and selectedCount == #packages
local headerChildren = {
ui.button({
key = "btn-collapse-flatpak",
glyph = flatpakCollapsed and "chevron-right" or "chevron-down",
variant = "ghost",
controlSize = "sm",
tooltip = flatpakCollapsed and tr("actions.expand") or tr("actions.collapse"),
onClick = function()
flatpakCollapsed = not flatpakCollapsed
render()
end,
}),
ui.label({
text = flatpakTitle,
fontSize = 12,
fontWeight = "bold",
color = "on_surface",
flexGrow = 1,
}),
}
if #packages > 0 then
table.insert(headerChildren, ui.button({
key = "btn-toggle-all-flatpak",
glyph = allSelected and "square-check" or "square",
variant = "ghost",
controlSize = "sm",
tooltip = allSelected and tr("actions.deselect_all") or tr("actions.select_all"),
enabled = not isBusy,
onClick = function()
toggleAllFlatpak(packages)
end,
}))
end
local items = {}
local header = ui.row({ key = "flatpak-section-header", align = "center", gap = 8, paddingV = 4 }, headerChildren)
table.insert(items, header)
local q = searchQuery:lower():match("^%s*(.-)%s*$") or ""
local visibleCount = 0
if not flatpakCollapsed then
for idx, pkg in ipairs(packages) do
local pkgKey = getFlatpakKey(pkg)
local isMatch = (q == "")
or (pkg.name and pkg.name:lower():find(q, 1, true) ~= nil)
or (pkg.id and pkg.id:lower():find(q, 1, true) ~= nil)
or (pkg.ref and pkg.ref:lower():find(q, 1, true) ~= nil)
if isMatch then
visibleCount += 1
local isExcluded = excludedFlatpak[pkgKey] == true
local subtitle = pkg.id or pkg.branch or ""
if pkg.origin and pkg.origin ~= "" then
subtitle = subtitle ~= "" and (subtitle .. " · " .. pkg.origin) or pkg.origin
end
if pkg.is_runtime then
subtitle = (subtitle ~= "" and (subtitle .. " · ") or "") .. tr("sections.runtime")
end
local sizeLabel = (pkg.download_formatted and pkg.download_formatted ~= "") and pkg.download_formatted or (pkg.install_formatted or "")
local nameRowChildren = {
ui.label({
text = pkg.name or "Unknown",
fontSize = 12,
fontWeight = "semibold",
color = isExcluded and "on_surface_variant" or "on_surface",
}),
}
if isExcluded then
table.insert(nameRowChildren, ui.label({
text = "(" .. tr("status.skipped") .. ")",
fontSize = 10,
color = "on_surface_variant",
}))
end
table.insert(items, ui.row({
key = "flatpak-pkg-" .. tostring(idx) .. "-" .. pkgKey,
padding = 8,
paddingH = 12,
fill = isExcluded and "surface_container" or "surface",
radius = 6,
align = "center",
gap = 8,
onClick = function()
if not isBusy then
toggleExcludeFlatpak(pkgKey)
end
end,
}, {
ui.glyph({
name = isExcluded and "square" or "square-check",
size = 18,
color = isExcluded and "on_surface_variant" or "secondary",
}),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.row({ align = "center", gap = 6 }, nameRowChildren),
ui.label({
text = subtitle,
fontSize = 11,
color = "on_surface_variant",
}),
}),
ui.column({ align = "end", gap = 2 }, {
ui.label({
text = sizeLabel,
fontSize = 11,
fontWeight = "bold",
color = isExcluded and "on_surface_variant" or "secondary",
}),
ui.label({
text = pkg.version or "",
fontSize = 10,
color = "on_surface_variant",
fontWeight = "semibold",
}),
}),
}))
end
end
if q ~= "" and visibleCount == 0 and #packages > 0 then
table.insert(items, ui.label({
key = "flatpak-no-match",
text = tr("search.no_matches", { query = searchQuery }),
fontSize = 11,
color = "on_surface_variant",
padding = 8,
}))
end
end
return items, selectedCount, selectedBytes
end
render = function()
enableDnf = noctalia.getConfig("enable_dnf") ~= false
enableFlatpak = noctalia.getConfig("enable_flatpak") ~= false
local state = updateState
local isBusy = state and (state.status == "CHECKING" or state.status == "UPDATING")
local total = state and state.total_count or 0
local pluginVersion = getPluginVersion()
local header = ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "package", size = 16, color = "primary" }),
ui.column({ key = "panel-title-block", gap = 0 }, {
ui.row({ align = "center", gap = 6 }, {
ui.label({
key = "panel-title",
text = tr("title"),
fontSize = 15,
fontWeight = "bold",
color = "on_surface",
}),
ui.label({
key = "panel-version",
text = pluginVersion ~= "" and ("v" .. pluginVersion) or "",
fontSize = 11,
color = "on_surface_variant",
}),
}),
ui.label({
key = "panel-last-checked",
text = (state and (state.last_update_timestamp or 0) > 0)
and (formatLastChecked(state) .. " · " .. tr("status.last_update", { time = formatAgo(state.last_update_timestamp) }))
or formatLastChecked(state),
fontSize = 10,
color = "on_surface_variant",
}),
}),
ui.spacer({ key = "header-spacer", flexGrow = 1 }),
ui.button({
key = "header-refresh",
glyph = "refresh",
variant = "ghost",
enabled = not isBusy,
tooltip = tr("actions.check_now"),
onClick = "onCheckNow",
}),
ui.button({
key = "header-settings",
glyph = "settings",
variant = "ghost",
tooltip = tr("actions.open_settings"),
onClick = "onOpenSettings",
}),
ui.button({
key = "header-close",
glyph = "close",
variant = "ghost",
tooltip = tr("actions.close"),
onClick = "onClosePanel",
}),
})
local rebootBanner = nil
if state and state.reboot_required then
rebootBanner = ui.row({
key = "reboot-banner",
padding = 8,
paddingH = 12,
fill = "warning/0.15",
radius = 8,
align = "center",
gap = 8,
}, {
ui.glyph({ name = "rotate-cw", size = 16, color = "warning" }),
ui.label({
text = tr("status.reboot_required"),
fontSize = 12,
fontWeight = "semibold",
color = "warning",
flexGrow = 1,
}),
})
end
local card = statusCard(state)
local searchBar = nil
if total > 0 or searchQuery ~= "" then
local searchChildren = {
ui.input({
key = "pkg-search-input",
value = searchQuery,
placeholder = tr("actions.search_placeholder"),
flexGrow = 1,
controlSize = "sm",
onChange = "onSearchChange",
}),
}
if searchQuery ~= "" then
table.insert(searchChildren, ui.button({
key = "btn-clear-search",
glyph = "close",
variant = "ghost",
controlSize = "sm",
tooltip = tr("actions.clear_search"),
onClick = "onClearSearch",
}))
end
searchBar = ui.row({ key = "search-row", align = "center", gap = 6 }, searchChildren)
end
-- Package list rows
local scrollItems = {}
local dnfItems, dnfSelCount, dnfSelBytes = renderDnfSection(state)
if dnfItems then
for _, item in ipairs(dnfItems) do
table.insert(scrollItems, item)
end
end
local flatpakItems, fpSelCount, fpSelBytes = renderFlatpakSection(state)
if flatpakItems then
for _, item in ipairs(flatpakItems) do
table.insert(scrollItems, item)
end
end
local listContainer
if #scrollItems > 0 then
listContainer = ui.scroll({ key = "packages-scroll", flexGrow = 1, gap = 6 }, scrollItems)
else
listContainer = ui.column({ key = "empty-filler", flexGrow = 1 }, {})
end
local totalSelected = (dnfSelCount or 0) + (fpSelCount or 0)
local totalSelectedBytes = (dnfSelBytes or 0) + (fpSelBytes or 0)
local btnText
if totalSelected == total then
if totalSelectedBytes > 0 then
btnText = tr("actions.update_all_size", { count = total, size = formatBytes(totalSelectedBytes) })
else
btnText = tr("actions.update_all")
end
else
if totalSelectedBytes > 0 then
btnText = tr("actions.update_selected_size", { count = totalSelected, size = formatBytes(totalSelectedBytes) })
else
btnText = tr("actions.update_selected", { count = totalSelected })
end
end
local footer = ui.column({ gap = 8 }, {
ui.separator({ orientation = "horizontal" }),
ui.row({ align = "center", gap = 8 }, {
ui.button({
key = "btn-update-all",
text = btnText,
glyph = "download",
variant = "primary",
flexGrow = 1,
enabled = (totalSelected > 0 and not isBusy),
onClick = "onUpdateAll",
}),
}),
})
local panelChildren = {
header,
}
if rebootBanner then
table.insert(panelChildren, rebootBanner)
end
table.insert(panelChildren, card)
if searchBar then
table.insert(panelChildren, searchBar)
end
table.insert(panelChildren, listContainer)
table.insert(panelChildren, footer)
panel.render(ui.column({ flexGrow = 1, gap = 6, align = "stretch" }, panelChildren))
end
function onSearchChange(text)
searchQuery = text or ""
render()
end
function onClearSearch()
searchQuery = ""
render()
end
function onOpen(_context)
updateState = noctalia.state.get("update_state")
searchQuery = ""
render()
end
function onClose()
-- Panel close hook
end
function onCheckNow()
noctalia.state.set("service_command", { action = "check_now", ts = os.time() })
end
-- The terminal takes over from here; the panel's own progress view only matters for pkexec runs.
local function closeIfTerminal()
if noctalia.getConfig("update_in_terminal") ~= false then
panel.close()
end
end
function onUpdateAll()
local excludedDnfList = {}
if updateState and updateState.dnf and updateState.dnf.packages then
for _, pkg in ipairs(updateState.dnf.packages) do
if excludedDnf[pkg.name] then
table.insert(excludedDnfList, pkg.name)
end
end
end
local selectedFlatpakList = {}
if updateState and updateState.flatpak and updateState.flatpak.packages then
for _, pkg in ipairs(updateState.flatpak.packages) do
local k = getFlatpakKey(pkg)
if not excludedFlatpak[k] then
table.insert(selectedFlatpakList, k)
end
end
end
noctalia.state.set("service_command", {
action = "update_all",
excluded_dnf = excludedDnfList,
selected_flatpak = selectedFlatpakList,
ts = os.time(),
})
closeIfTerminal()
end
function onUpdateDnf()
local excludedDnfList = {}
if updateState and updateState.dnf and updateState.dnf.packages then
for _, pkg in ipairs(updateState.dnf.packages) do
if excludedDnf[pkg.name] then
table.insert(excludedDnfList, pkg.name)
end
end
end
noctalia.state.set("service_command", {
action = "update_dnf",
excluded_dnf = excludedDnfList,
ts = os.time(),
})
closeIfTerminal()
end
function onUpdateFlatpak()
local selectedFlatpakList = {}
if updateState and updateState.flatpak and updateState.flatpak.packages then
for _, pkg in ipairs(updateState.flatpak.packages) do
local k = getFlatpakKey(pkg)
if not excludedFlatpak[k] then
table.insert(selectedFlatpakList, k)
end
end
end
noctalia.state.set("service_command", {
action = "update_flatpak",
selected_flatpak = selectedFlatpakList,
ts = os.time(),
})
closeIfTerminal()
end
function onOpenSettings()
noctalia.openSettings()
end
function onClosePanel()
panel.close()
end
-- Subscribe to state changes from background service
noctalia.state.watch("update_state", function(newState)
updateState = newState
render()
end)
render()