1010 lines
28 KiB
Luau
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()
|