316 lines
11 KiB
Luau
316 lines
11 KiB
Luau
--!nonstrict
|
|
-- portctl — panel entry.
|
|
|
|
-- ── Module state ──────────────────────────────────────────────────────────
|
|
|
|
local data = noctalia.state.get("portctl_data") or { ports = {}, error = nil }
|
|
local killingPid = nil -- pid currently being killed (orange bullet)
|
|
local pendingKill = nil -- slot index staged for kill (first click — shows checkmark)
|
|
local searchQuery = ""
|
|
local showTCP = true
|
|
local showUDP = false
|
|
|
|
-- Kill flow (no overlay): first click stages slot (pendingKill=i, icon→check),
|
|
-- second click on same slot confirms kill.
|
|
|
|
local rowCursor = 0
|
|
|
|
local function killAt(slot, entry)
|
|
if entry.pid == 0 then return end
|
|
if pendingKill == slot then
|
|
killingPid = entry.pid
|
|
pendingKill = nil
|
|
noctalia.state.set("portctl_command", { action = "kill", pid = entry.pid })
|
|
render()
|
|
else
|
|
pendingKill = slot
|
|
render()
|
|
end
|
|
end
|
|
|
|
local function copyPid(entry)
|
|
if entry.pid == 0 then return end
|
|
local ok = noctalia.copyToClipboard(tostring(entry.pid), "text/plain")
|
|
if ok then noctalia.notify(noctalia.tr("title"), noctalia.tr("panel.pid_copied", { pid = tostring(entry.pid) })) end
|
|
end
|
|
|
|
-- ── Category metadata ─────────────────────────────────────────────────────
|
|
|
|
local CAT_ORDER = { "Development", "Databases", "Containers", "Servers", "Cloud", "Other" }
|
|
|
|
local CAT_META = {
|
|
Development = { color = "primary", icon = "code" },
|
|
Databases = { color = "tertiary", icon = "database" },
|
|
Containers = { color = "secondary", icon = "box" },
|
|
Servers = { color = "error", icon = "server" },
|
|
Cloud = { color = "warning", icon = "cloud" },
|
|
Other = { color = "on_surface_variant", icon = "plug" },
|
|
}
|
|
|
|
local COLOR_ACTIVE = "#22c55e"
|
|
local COLOR_KILLING = "warning"
|
|
|
|
-- ── Filtering & grouping ──────────────────────────────────────────────────
|
|
|
|
local function filteredPorts()
|
|
local ports = data.ports or {}
|
|
if searchQuery == "" and showTCP and showUDP then return ports end
|
|
local q = searchQuery:lower()
|
|
local out = {}
|
|
for _, p in ipairs(ports) do
|
|
if p.proto == "tcp" and not showTCP then continue end
|
|
if p.proto == "udp" and not showUDP then continue end
|
|
if searchQuery ~= "" then
|
|
if not (tostring(p.port):find(q, 1, true)
|
|
or p.name:lower():find(q, 1, true)
|
|
or tostring(p.pid):find(q, 1, true)) then
|
|
continue
|
|
end
|
|
end
|
|
table.insert(out, p)
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function groupByCategory(ports)
|
|
local groups = {}
|
|
local seen = {}
|
|
for _, p in ipairs(ports) do
|
|
local cat = p.category or "Other"
|
|
if not groups[cat] then groups[cat] = {}; table.insert(seen, cat) end
|
|
table.insert(groups[cat], p)
|
|
end
|
|
local ordered = {}
|
|
for _, cat in ipairs(CAT_ORDER) do
|
|
if groups[cat] then table.insert(ordered, cat) end
|
|
end
|
|
for _, cat in ipairs(seen) do
|
|
local inOrder = false
|
|
for _, oc in ipairs(CAT_ORDER) do if oc == cat then inOrder = true; break end end
|
|
if not inOrder then table.insert(ordered, cat) end
|
|
end
|
|
return groups, ordered
|
|
end
|
|
|
|
-- ── Port row ──────────────────────────────────────────────────────────────
|
|
|
|
local function _doCancel(i)
|
|
if pendingKill == i then pendingKill = nil; render() end
|
|
end
|
|
|
|
local function portRow(entry)
|
|
rowCursor += 1
|
|
local slot = rowCursor
|
|
local hasKill = entry.pid ~= 0
|
|
local isKilling = (killingPid == entry.pid)
|
|
local isPending = (pendingKill == slot)
|
|
|
|
local killFn = function() killAt(slot, entry) end
|
|
local copyFn = function() copyPid(entry) end
|
|
local cancelFn = function() _doCancel(slot) end
|
|
local dotClr = isKilling and COLOR_KILLING or COLOR_ACTIVE
|
|
|
|
-- Killing state: replace entire row content with port + spinner
|
|
if isKilling then
|
|
return ui.row({
|
|
align = "center",
|
|
gap = 8,
|
|
paddingH = 16,
|
|
paddingV = 8,
|
|
}, {
|
|
ui.glyph({ name = "circle-filled", size = 8, color = COLOR_KILLING }),
|
|
ui.label({ text = ":" .. tostring(entry.port), fontWeight = "bold", minWidth = 58, fontSize = 13 }),
|
|
ui.label({ text = entry.proto:upper(), color = "on_surface_variant", fontSize = 10, minWidth = 34 }),
|
|
ui.label({ text = noctalia.tr("panel.killing", { name = entry.name }), flexGrow = 1, fontSize = 13, color = "on_surface_variant" }),
|
|
ui.button({ glyph = "loader-circle", variant = "ghost", disabled = true, opacity = 0.5, paddingH = 2, paddingV = 2 }),
|
|
})
|
|
end
|
|
|
|
-- Pending state: row transforms to inline confirm
|
|
if isPending then
|
|
return ui.row({
|
|
align = "center",
|
|
gap = 8,
|
|
paddingH = 16,
|
|
paddingV = 8,
|
|
fill = "error/0.06",
|
|
}, {
|
|
ui.glyph({ name = "circle-filled", size = 8, color = COLOR_ACTIVE }),
|
|
ui.label({ text = ":" .. tostring(entry.port), fontWeight = "bold", minWidth = 58, fontSize = 13 }),
|
|
ui.label({ text = entry.proto:upper(), color = "on_surface_variant", fontSize = 10, minWidth = 34 }),
|
|
ui.label({ text = noctalia.tr("panel.kill_confirm", { name = entry.name }), flexGrow = 1, fontSize = 13, color = "error" }),
|
|
ui.button({ text = noctalia.tr("panel.cancel"), variant = "ghost", onClick = cancelFn, paddingH = 6, paddingV = 2 }),
|
|
ui.button({ text = noctalia.tr("panel.kill"), variant = "destructive", onClick = killFn, paddingH = 6, paddingV = 2 }),
|
|
})
|
|
end
|
|
|
|
-- Normal state
|
|
return ui.row({
|
|
align = "center",
|
|
gap = 8,
|
|
paddingH = 16,
|
|
paddingV = 8,
|
|
}, {
|
|
ui.glyph({ name = "circle-filled", size = 8, color = dotClr }),
|
|
ui.label({ text = ":" .. tostring(entry.port), fontWeight = "bold", minWidth = 58, fontSize = 13 }),
|
|
ui.label({ text = entry.proto:upper(), color = "on_surface_variant", fontSize = 10, minWidth = 34 }),
|
|
ui.label({ text = entry.name, flexGrow = 1, fontSize = 13 }),
|
|
ui.button({
|
|
text = "PID " .. (hasKill and tostring(entry.pid) or "—"),
|
|
variant = "ghost",
|
|
fontSize = 11,
|
|
color = "on_surface_variant",
|
|
onClick = hasKill and copyFn or nil,
|
|
paddingH = 4,
|
|
paddingV = 2,
|
|
}),
|
|
ui.button({
|
|
glyph = "x",
|
|
variant = "ghost",
|
|
onClick = killFn,
|
|
opacity = hasKill and 1 or 0,
|
|
paddingH = 2,
|
|
paddingV = 2,
|
|
}),
|
|
})
|
|
end
|
|
|
|
-- ── Category section ──────────────────────────────────────────────────────
|
|
|
|
local function catSection(catName, ports)
|
|
local meta = CAT_META[catName] or CAT_META.Other
|
|
local rows = {}
|
|
|
|
table.insert(rows, ui.row({
|
|
align = "center",
|
|
gap = 6,
|
|
paddingH = 16,
|
|
paddingV = 7,
|
|
fill = "surface_container/0.4",
|
|
}, {
|
|
ui.glyph({ name = meta.icon, size = 12, color = meta.color }),
|
|
ui.label({ text = noctalia.tr("categories." .. catName:lower()), fontSize = 11, fontWeight = "bold", color = meta.color }),
|
|
ui.label({ text = tostring(#ports), fontSize = 10, color = meta.color .. "/0.6" }),
|
|
}))
|
|
|
|
for _, p in ipairs(ports) do
|
|
table.insert(rows, portRow(p))
|
|
end
|
|
|
|
return ui.column({ gap = 0 }, rows)
|
|
end
|
|
|
|
-- ── Main render ───────────────────────────────────────────────────────────
|
|
|
|
function render()
|
|
data = noctalia.state.get("portctl_data") or data
|
|
|
|
rowCursor = 0
|
|
|
|
local ports = filteredPorts()
|
|
noctalia.state.set("portctl_visible_count", #ports)
|
|
|
|
local header = ui.row({
|
|
align = "center",
|
|
gap = 6,
|
|
paddingH = 10,
|
|
paddingV = 10,
|
|
}, {
|
|
ui.input({
|
|
key = "search",
|
|
placeholder = noctalia.tr("panel.search_placeholder"),
|
|
flexGrow = 1,
|
|
onChange = "onSearchChange",
|
|
}),
|
|
ui.toggle({ checked = showTCP, onChange = "onToggleTCP" }),
|
|
ui.label({ text = noctalia.tr("panel.tcp"), fontSize = 10, color = "on_surface_variant" }),
|
|
ui.toggle({ checked = showUDP, onChange = "onToggleUDP" }),
|
|
ui.label({ text = noctalia.tr("panel.udp"), fontSize = 10, color = "on_surface_variant" }),
|
|
ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }),
|
|
ui.button({ glyph = "x", variant = "ghost", onClick = "onClose" }),
|
|
})
|
|
|
|
local body
|
|
if data.error then
|
|
body = ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 12 }, {
|
|
ui.glyph({ name = "alert-circle", size = 36, color = "error" }),
|
|
ui.label({ text = data.error, color = "on_surface_variant", textAlign = "center", fontSize = 13 }),
|
|
})
|
|
elseif #ports == 0 then
|
|
local msg = searchQuery ~= ""
|
|
and noctalia.tr("panel.no_results", { query = searchQuery })
|
|
or noctalia.tr("panel.no_ports")
|
|
body = ui.column({ flexGrow = 1, align = "center", justify = "center", gap = 12 }, {
|
|
ui.glyph({ name = "plug-off", size = 36, color = "on_surface_variant" }),
|
|
ui.label({ text = msg, color = "on_surface_variant", fontSize = 13 }),
|
|
})
|
|
else
|
|
local groups, order = groupByCategory(ports)
|
|
local sections = {}
|
|
for i, cat in ipairs(order) do
|
|
table.insert(sections, catSection(cat, groups[cat]))
|
|
if i < #order then
|
|
table.insert(sections, ui.separator({ thickness = 1, color = "outline_variant" }))
|
|
end
|
|
end
|
|
body = ui.scroll({ flexGrow = 1 }, {
|
|
ui.column({ gap = 0 }, sections),
|
|
})
|
|
end
|
|
|
|
panel.render(ui.column({ flexGrow = 1, gap = 0 }, {
|
|
header,
|
|
ui.separator({ thickness = 1, color = "outline_variant" }),
|
|
body,
|
|
}))
|
|
end
|
|
|
|
-- ── Global handlers ───────────────────────────────────────────────────────
|
|
|
|
function onSearchChange(value)
|
|
searchQuery = value or ""
|
|
pendingKill = nil
|
|
render()
|
|
end
|
|
|
|
function onToggleTCP(value)
|
|
showTCP = value ~= "false"
|
|
pendingKill = nil
|
|
render()
|
|
end
|
|
|
|
function onToggleUDP(value)
|
|
showUDP = value ~= "false"
|
|
pendingKill = nil
|
|
render()
|
|
end
|
|
|
|
function onRefresh()
|
|
pendingKill = nil
|
|
noctalia.state.set("portctl_refresh", os.time())
|
|
end
|
|
|
|
function onClose()
|
|
panel.close()
|
|
end
|
|
|
|
-- ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
|
|
function onOpen(_ctx)
|
|
killingPid = nil
|
|
pendingKill = nil
|
|
searchQuery = ""
|
|
noctalia.state.set("portctl_refresh", os.time())
|
|
render()
|
|
end
|
|
|
|
noctalia.state.watch("portctl_data", function(d)
|
|
if not d then return end
|
|
data = d
|
|
killingPid = nil
|
|
pendingKill = nil
|
|
render()
|
|
end)
|
|
|
|
render()
|