diff --git a/cmd-runner/README.md b/cmd-runner/README.md new file mode 100644 index 00000000..ae463846 --- /dev/null +++ b/cmd-runner/README.md @@ -0,0 +1,41 @@ +# Command Runner + +Save and execute CLI commands silently in the background with a single click and secure sudo password cache. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `nocode-96/cmd-runner` | +| Entries | Bar widget: `cmd-runner`; panel: `panel`; service: `cmd-service` | + +## Requirements + +No special system requirements. Requires Noctalia v5. + +## Usage + +Add the `cmd-runner` widget to a bar. Left-click it to open the command panel, where you can: +- **Run**: Execute custom CLI commands silently in the background. +- **Add**: Create a new command with a custom name, CLI command line, and optional sudo password. +- **Edit**: Edit an existing command. The sudo password is kept hidden and is not pre-populated in plain text. +- **Delete**: Remove a saved command. +- **Log**: Click the logs button on a command to view stdout/stderr output. + +Open the panel directly with: + +```sh +noctalia msg panel-toggle nocode-96/cmd-runner:panel +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `show_toast` | `bool` | `true` | Shows a toast notification when a command completes or fails. | +| `show_label` | `bool` | `true` | Displays 'Commands' next to the icon in the top bar. | + +## Notes + +Command Runner runs custom CLI command lines in the background. If a command requires sudo (root) privileges, the password is encrypted locally in `commands.json` (inside the plugin's data folder) using base64 and character-shifting. The password is never sent in the public UI state, preventing other plugins from harvesting it. + diff --git a/cmd-runner/commands.json b/cmd-runner/commands.json new file mode 100644 index 00000000..5a662210 --- /dev/null +++ b/cmd-runner/commands.json @@ -0,0 +1 @@ +[{"command":"echo 'Checking system updates...' && sleep 1 && echo 'System is up to date'","icon":"refresh","id":"cmd_demo_1","isSudo":false,"name":"System Update Check","sudoPassword":""},{"command":"sudo hda-verb /dev/snd/hwC1D0 0x1d SET_PIN_WIDGET_CONTROL 0x0","icon":"terminal","id":"cmd_304120","isSudo":true,"name":"hda","sudoPassword":"enc:oKOYrQ=="}] \ No newline at end of file diff --git a/cmd-runner/panel.luau b/cmd-runner/panel.luau new file mode 100644 index 00000000..819e818d --- /dev/null +++ b/cmd-runner/panel.luau @@ -0,0 +1,341 @@ +--!nonstrict +-- Panel entry for Command Runner plugin + +local state = noctalia.state.get("cmd_runner_state") or { + commands = {}, + runningMap = {}, + outputMap = {}, + exitCodeMap = {} +} + +-- UI state +local isFormOpen = false +local formGen = 1 +local editId = "" +local editName = "" +local editCommand = "" +local editIcon = "terminal" +local editIsSudo = false +local editSudoPassword = "" +local hasPassword = false +local activeLogId = "" + +local render + +local function tr(key, values) + return noctalia.tr(key, values) +end + +local function sendAction(actData) + noctalia.state.set("cmd_runner_action", actData) +end + +local function openAddForm() + editId = "" + editName = "" + editCommand = "" + editIcon = "terminal" + editIsSudo = false + editSudoPassword = "" + hasPassword = false + formGen = formGen + 1 + isFormOpen = true + render() +end + +local function openEditForm(cmd) + if not cmd then return end + editId = cmd.id or "" + editName = cmd.name or "" + editCommand = cmd.command or "" + editIcon = cmd.icon or "terminal" + editIsSudo = cmd.isSudo == true + editSudoPassword = "" -- Never pre-fill the password for security + hasPassword = cmd.hasPassword == true + formGen = formGen + 1 + isFormOpen = true + render() +end + +local function closeForm() + isFormOpen = false + render() +end + +local function saveForm() + local trimmedName = noctalia.string.trim(editName) + local trimmedCmd = noctalia.string.trim(editCommand) + if trimmedName == "" or trimmedCmd == "" then return end + + local item = { + id = editId ~= "" and editId or ("cmd_" .. tostring(math.random(100000, 999999))), + name = trimmedName, + command = trimmedCmd, + icon = editIcon ~= "" and editIcon or "terminal", + isSudo = editIsSudo, + sudoPassword = editSudoPassword + } + + if editId ~= "" then + sendAction({ action = "update", item = item }) + else + sendAction({ action = "add", item = item }) + end + closeForm() +end + +-- Build the edit/add form UI +local function renderForm() + local isEditing = editId ~= "" + + local formItems = { + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ + text = isEditing and tr("panel.edit") or tr("panel.add_button"), + fontWeight = "bold", + fontSize = 14, + color = "primary" + }), + ui.button({ + glyph = "close", + variant = "ghost", + onClick = function() closeForm() end + }) + }), + ui.column({ gap = 4 }, { + ui.label({ text = tr("panel.name_label"), fontSize = 12, color = "on_surface_variant" }), + ui.input({ + key = "edit_name_" .. tostring(formGen), + value = editName, + placeholder = "z.B. System Update", + onChange = function(val) editName = val or "" end + }) + }), + ui.column({ gap = 4 }, { + ui.label({ text = tr("panel.cmd_label"), fontSize = 12, color = "on_surface_variant" }), + ui.input({ + key = "edit_cmd_" .. tostring(formGen), + value = editCommand, + placeholder = "z.B. apt update && apt upgrade -y", + onChange = function(val) editCommand = val or "" end + }) + }), + ui.button({ + text = editIsSudo and "[✓] Sudo (root) erforderlich" or "[ ] Sudo (root) erforderlich", + variant = editIsSudo and "primary" or "secondary", + onClick = function() + editIsSudo = not editIsSudo + render() + end + }) + } + + if editIsSudo then + local placeholderText = "Sudo-Passwort..." + if isEditing and hasPassword then + placeholderText = "Passwort gespeichert (leer lassen zum Behalten)" + end + + table.insert(formItems, ui.column({ gap = 4 }, { + ui.label({ text = tr("panel.sudo_pass_label"), fontSize = 12, color = "primary", fontWeight = "bold" }), + ui.input({ + key = "edit_pass_" .. tostring(formGen), + value = editSudoPassword, + placeholder = placeholderText, + secret = true, + onChange = function(val) editSudoPassword = val or "" end + }) + })) + end + + table.insert(formItems, ui.row({ justify = "end", gap = 8 }, { + ui.button({ + text = tr("panel.cancel_button"), + variant = "ghost", + onClick = function() closeForm() end + }), + ui.button({ + text = tr("panel.save_button"), + variant = "primary", + onClick = function() saveForm() end + }) + })) + + return ui.column({ fill = "surface_variant/0.3", radius = 10, padding = 12, gap = 12 }, formItems) +end + +-- Render a single command card +local function renderCommandCard(cmd) + local runningMap = (type(state) == "table" and type(state.runningMap) == "table") and state.runningMap or {} + local outputMap = (type(state) == "table" and type(state.outputMap) == "table") and state.outputMap or {} + local exitCodeMap = (type(state) == "table" and type(state.exitCodeMap) == "table") and state.exitCodeMap or {} + + local isRunning = runningMap[cmd.id] == true + local hasOutput = outputMap[cmd.id] ~= nil + local exitCode = exitCodeMap[cmd.id] + local isLogOpen = activeLogId == cmd.id + + -- Status badge + local statusGlyph = nil + if isRunning then + statusGlyph = ui.glyph({ name = "loader-2", size = 14, color = "primary" }) + elseif exitCode == 0 then + statusGlyph = ui.glyph({ name = "check", size = 14, color = "success" }) + elseif exitCode ~= nil then + statusGlyph = ui.glyph({ name = "x", size = 14, color = "error" }) + end + + -- Header line inside card + local cardHeader = { + ui.glyph({ name = cmd.icon or "terminal", size = 16, color = "primary" }), + ui.label({ text = cmd.name or "", fontWeight = "bold", fontSize = 13, color = "on_surface", flexGrow = 1, maxLines = 1 }) + } + if cmd.isSudo then + table.insert(cardHeader, ui.label({ text = "[sudo]", fontSize = 10, color = "primary", fontWeight = "bold" })) + end + if statusGlyph then + table.insert(cardHeader, statusGlyph) + end + + -- Action buttons row + local actionButtons = { + ui.button({ + text = isRunning and tr("panel.running") or tr("panel.run_button"), + glyph = isRunning and "loader-2" or "player-play", + variant = "primary", + enabled = not isRunning, + onClick = function() + sendAction({ action = "run", id = cmd.id }) + end + }), + ui.button({ + glyph = "edit", + text = tr("panel.edit"), + variant = "secondary", + onClick = function() + openEditForm(cmd) + end + }), + ui.button({ + glyph = "trash", + variant = "destructive", + onClick = function() + sendAction({ action = "delete", id = cmd.id }) + if activeLogId == cmd.id then activeLogId = "" end + render() + end + }) + } + + if hasOutput then + table.insert(actionButtons, ui.button({ + glyph = isLogOpen and "chevron-up" or "code", + text = tr("panel.logs"), + variant = "ghost", + onClick = function() + activeLogId = isLogOpen and "" or cmd.id + render() + end + })) + end + + local cardItems = { + ui.row({ align = "center", gap = 8 }, cardHeader), + ui.label({ text = cmd.command or "", fontSize = 11, color = "on_surface_variant", maxLines = 2 }), + ui.row({ align = "center", gap = 6, justify = "end" }, actionButtons) + } + + -- Log output preview if open + if isLogOpen and hasOutput then + local outText = outputMap[cmd.id] or "" + local logHeader = {} + if exitCode == 0 then + table.insert(logHeader, ui.label({ text = "✓ Status: Success (Code 0)", fontSize = 10, color = "success", fontWeight = "bold" })) + elseif exitCode ~= nil then + table.insert(logHeader, ui.label({ text = "✗ Status: Failed (Code " .. tostring(exitCode) .. ")", fontSize = 10, color = "error", fontWeight = "bold" })) + end + + table.insert(logHeader, ui.label({ text = outText, fontSize = 10, color = "on_surface_variant", maxLines = 12 })) + + table.insert(cardItems, ui.column({ + fill = "surface_variant/0.5", radius = 6, padding = 8, gap = 4 + }, logHeader)) + end + + return ui.column({ + fill = "surface_variant/0.25", radius = 8, padding = 10, gap = 8 + }, cardItems) +end + +render = function() + local commands = (type(state) == "table" and type(state.commands) == "table") and state.commands or {} + local children = {} + + -- Header Row 1: Icon + Title + Close Button + table.insert(children, ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = "terminal", size = 18, color = "primary" }), + ui.label({ text = tr("panel.title"), fontSize = 15, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), + ui.button({ + glyph = "close", + variant = "ghost", + onClick = function() panel.close() end + }) + })) + + -- Header Row 2: Subtitle + Neuer Befehl Button (No collision!) + if not isFormOpen then + table.insert(children, ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.column({ flexGrow = 1, overflow = "hidden" }, { + ui.label({ text = tr("panel.subtitle"), fontSize = 10, color = "on_surface_variant", maxLines = 1 }) + }), + ui.button({ + glyph = "plus", + text = tr("panel.add_button"), + variant = "primary", + onClick = function() openAddForm() end + }) + })) + end + + -- Form or Command List + if isFormOpen then + table.insert(children, renderForm()) + else + if #commands == 0 then + -- Empty state + table.insert(children, ui.column({ align = "center", gap = 12, padding = 32 }, { + ui.glyph({ name = "terminal", size = 42, color = "on_surface_variant" }), + ui.label({ text = tr("panel.no_commands"), color = "on_surface_variant", fontSize = 13 }), + ui.button({ + glyph = "plus", + text = tr("panel.add_button"), + variant = "primary", + onClick = function() openAddForm() end + }) + })) + else + -- List of command cards + local cardList = {} + for _, cmd in ipairs(commands) do + table.insert(cardList, renderCommandCard(cmd)) + end + + table.insert(children, ui.scroll({ flexGrow = 1, gap = 8 }, cardList)) + end + end + + panel.render(ui.column({ flexGrow = 1, gap = 8, padding = 4 }, children)) +end + +-- State watchers +noctalia.state.watch("cmd_runner_state", function(val) + if type(val) == "table" then + state = val + render() + end +end) + +-- Lifecycle +function onOpen() + render() +end diff --git a/cmd-runner/plugin.toml b/cmd-runner/plugin.toml new file mode 100644 index 00000000..9fc8dd8b --- /dev/null +++ b/cmd-runner/plugin.toml @@ -0,0 +1,41 @@ +id = "nocode-96/cmd-runner" +name = "Command Runner" +version = "1.0.0" +plugin_api = 9 +author = "Noah B." +license = "MIT" +icon = "terminal" +description = "Save and execute CLI commands silently in the background with a one-time sudo password cache." +dependencies = [] +tags = ["bar", "panel", "service", "utility", "system"] + +[[setting]] +key = "show_toast" +type = "bool" +label_key = "settings.show_toast.label" +description_key = "settings.show_toast.description" +default = true + +[[widget]] +id = "cmd-runner" +entry = "widget.luau" + + [[widget.setting]] + key = "show_label" + type = "bool" + label_key = "settings.show_label.label" + description_key = "settings.show_label.description" + default = true + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 460 +height = 540 +placement = "attached" +position = "auto" +open_near_click = true + +[[service]] +id = "cmd-service" +entry = "service.luau" diff --git a/cmd-runner/service.luau b/cmd-runner/service.luau new file mode 100644 index 00000000..b54508eb --- /dev/null +++ b/cmd-runner/service.luau @@ -0,0 +1,255 @@ +--!nonstrict +-- Service entry for Command Runner plugin + +local SALT = "noctalia_cmd_runner_salt_5f8a" + +local function trim(s) + return noctalia.string.trim(tostring(s or "")) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +-- Helper functions for base64 encoding and encryption/decryption +local function base64Encode(data) + local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + return ((data:gsub('.', function(x) + local r,b_val='',x:byte() + for i=8,1,-1 do r=r..(b_val%2^i-b_val%2^(i-1)>0 and '1' or '0') end + return r; + end)..'0000'):gsub('%d%d%d%d%d%d', function(x) + if (#x < 6) then return '' end + local c=0 + for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end + return b:sub(c+1,c+1) + end)..({ '', '==', '=' })[#data%3+1]) +end + +local function base64Decode(data) + local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + data = string.gsub(data, '[^'..b..'=]', '') + return (data:gsub('.', function(x) + if (x == '=') then return '' end + local r,f='',(b:find(x)-1) + for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end + return r; + end):gsub('%d%d%d%d%d%d%d%d', function(x) + local c=0 + for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end + return string.char(c) + end)) +end + +local function encryptPassword(pass) + if not pass or pass == "" then return "" end + if pass:sub(1, 4) == "enc:" then return pass end -- already encrypted + + local result = {} + for i = 1, #pass do + local p_byte = pass:byte(i) + local s_byte = SALT:byte(((i - 1) % #SALT) + 1) + local enc_byte = (p_byte + s_byte) % 256 + table.insert(result, string.char(enc_byte)) + end + return "enc:" .. base64Encode(table.concat(result)) +end + +local function decryptPassword(enc) + if not enc or enc == "" then return "" end + if enc:sub(1, 4) ~= "enc:" then return enc end -- plain text fallback + + local raw_enc = enc:sub(5) + local decoded = base64Decode(raw_enc) + local result = {} + for i = 1, #decoded do + local d_byte = decoded:byte(i) + local s_byte = SALT:byte(((i - 1) % #SALT) + 1) + local dec_byte = (d_byte - s_byte) % 256 + table.insert(result, string.char(dec_byte)) + end + return table.concat(result) +end + +local defaultCommands = { + { + id = "cmd_demo_1", + name = "System Update Check", + command = "echo 'Checking system updates...' && sleep 1 && echo 'System is up to date'", + icon = "refresh", + isSudo = false, + sudoPassword = "" + } +} + +local commandsFile = (noctalia.pluginDir() or "") .. "/commands.json" + +local function loadCommands() + local content = noctalia.readFile(commandsFile) + if content and trim(content) ~= "" then + local decoded, err = noctalia.json.decode(content) + if type(decoded) == "table" and #decoded >= 0 then + -- Auto-encrypt passwords on load if they are stored in plain text + local changed = false + for _, c in ipairs(decoded) do + if c.sudoPassword and c.sudoPassword ~= "" and c.sudoPassword:sub(1, 4) ~= "enc:" then + c.sudoPassword = encryptPassword(c.sudoPassword) + changed = true + end + end + if changed then + local encoded = noctalia.json.encode(decoded) + if encoded then + noctalia.writeFile(commandsFile, encoded) + end + end + return decoded + end + end + return defaultCommands +end + +local function saveCommands(list) + local savedList = {} + for _, c in ipairs(list) do + local copy = {} + for k, v in pairs(c) do + copy[k] = v + end + if copy.sudoPassword then + copy.sudoPassword = encryptPassword(copy.sudoPassword) + end + table.insert(savedList, copy) + end + + local encoded, err = noctalia.json.encode(savedList) + if encoded then + noctalia.writeFile(commandsFile, encoded) + end +end + +local currentCommands = loadCommands() +local runningMap = {} +local outputMap = {} +local exitCodeMap = {} + +local function publishState() + -- Scrub actual passwords from public state for security + local publicCommands = {} + for _, c in ipairs(currentCommands) do + local copy = {} + for k, v in pairs(c) do + if k ~= "sudoPassword" then + copy[k] = v + else + copy.hasPassword = (v ~= nil and v ~= "") + end + end + table.insert(publicCommands, copy) + end + + noctalia.state.set("cmd_runner_state", { + commands = publicCommands, + runningMap = runningMap, + outputMap = outputMap, + exitCodeMap = exitCodeMap, + updatedAt = os.time() + }) +end + +publishState() + +local function executeCommand(cmdId) + local targetCmd = nil + for _, c in ipairs(currentCommands) do + if c.id == cmdId then + targetCmd = c + break + end + end + + if not targetCmd or trim(targetCmd.command) == "" then + return + end + + runningMap[cmdId] = true + publishState() + + local fullCmd = "" + if targetCmd.isSudo then + local pass = decryptPassword(targetCmd.sudoPassword) + fullCmd = "printf '%s\\n' " .. shellQuote(pass) .. " | sudo -S -p '' -- " .. targetCmd.command + else + fullCmd = targetCmd.command + end + + noctalia.runAsync(fullCmd, function(result) + runningMap[cmdId] = false + local code = (result and type(result.exitCode) == "number") and result.exitCode or -1 + local outText = trim((result and result.stdout or "") .. "\n" .. (result and result.stderr or "")) + exitCodeMap[cmdId] = code + outputMap[cmdId] = outText + publishState() + + local showToast = noctalia.getConfig("show_toast") ~= false + if showToast then + if code == 0 then + noctalia.notify(noctalia.tr("title"), noctalia.tr("notify.success", { name = targetCmd.name })) + else + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notify.error", { name = targetCmd.name, code = code })) + end + end + end, 120000) +end + +local function handleCommandAction(actionData) + if type(actionData) ~= "table" then return end + + local act = actionData.action + if act == "run" and actionData.id then + executeCommand(actionData.id) + elseif act == "add" and actionData.item then + local item = actionData.item + item.sudoPassword = encryptPassword(item.sudoPassword or "") + table.insert(currentCommands, item) + saveCommands(currentCommands) + publishState() + elseif act == "update" and actionData.item then + for i, c in ipairs(currentCommands) do + if c.id == actionData.item.id then + local newPass = actionData.item.sudoPassword or "" + -- If the updated command keeps sudo but the sent password is empty and we had a password, keep it + if newPass == "" and c.sudoPassword and c.sudoPassword ~= "" and actionData.item.isSudo then + newPass = c.sudoPassword + else + newPass = encryptPassword(newPass) + end + + local updatedItem = { + id = actionData.item.id, + name = actionData.item.name, + command = actionData.item.command, + icon = actionData.item.icon, + isSudo = actionData.item.isSudo, + sudoPassword = newPass + } + currentCommands[i] = updatedItem + break + end + end + saveCommands(currentCommands) + publishState() + elseif act == "delete" and actionData.id then + local newList = {} + for _, c in ipairs(currentCommands) do + if c.id ~= actionData.id then + table.insert(newList, c) + end + end + currentCommands = newList + saveCommands(currentCommands) + publishState() + end +end + +noctalia.state.watch("cmd_runner_action", handleCommandAction) diff --git a/cmd-runner/thumbnail.webp b/cmd-runner/thumbnail.webp new file mode 100644 index 00000000..175fba42 Binary files /dev/null and b/cmd-runner/thumbnail.webp differ diff --git a/cmd-runner/translations/de.json b/cmd-runner/translations/de.json new file mode 100644 index 00000000..23895b7d --- /dev/null +++ b/cmd-runner/translations/de.json @@ -0,0 +1,38 @@ +{ + "title": "Command Runner", + "widget": { + "label": "Befehle", + "tooltip": "Command Runner - CLI-Befehle im Hintergrund ausführen" + }, + "panel": { + "title": "Command Runner", + "subtitle": "Befehle per Knopfdruck lautlos im Hintergrund ausführen", + "add_button": "Neuer Befehl", + "cancel_button": "Abbrechen", + "save_button": "Speichern", + "run_button": "Ausführen", + "running": "Läuft...", + "logs": "Log", + "delete": "Löschen", + "edit": "Bearbeiten", + "name_label": "Befehlsname", + "cmd_label": "CLI Befehl", + "sudo_label": "Sudo (root) erforderlich", + "sudo_pass_label": "Sudo-Passwort (einmalig)", + "no_commands": "Keine Befehle vorhanden." + }, + "settings": { + "show_toast": { + "label": "Benachrichtigungen anzeigen", + "description": "Zeigt eine Toast-Meldung bei Abschluss oder Fehler eines Befehls an" + }, + "show_label": { + "label": "Text-Label in der Bar anzeigen", + "description": "Blendet den Text 'Befehle' neben dem Icon in der Top-Bar ein" + } + }, + "notify": { + "success": "Befehl '{name}' erfolgreich ausgeführt.", + "error": "Befehl '{name}' fehlgeschlagen (Exit Code {code})." + } +} diff --git a/cmd-runner/translations/en.json b/cmd-runner/translations/en.json new file mode 100644 index 00000000..b019b663 --- /dev/null +++ b/cmd-runner/translations/en.json @@ -0,0 +1,38 @@ +{ + "title": "Command Runner", + "widget": { + "label": "Commands", + "tooltip": "Command Runner - Run CLI commands in background" + }, + "panel": { + "title": "Command Runner", + "subtitle": "Execute commands silently in background at a click", + "add_button": "New Command", + "cancel_button": "Cancel", + "save_button": "Save", + "run_button": "Run", + "running": "Running...", + "logs": "Log", + "delete": "Delete", + "edit": "Edit", + "name_label": "Command Name", + "cmd_label": "CLI Command", + "sudo_label": "Requires Sudo (root)", + "sudo_pass_label": "Sudo Password (entered once)", + "no_commands": "No commands saved." + }, + "settings": { + "show_toast": { + "label": "Show notifications", + "description": "Shows a toast notice upon command completion or error" + }, + "show_label": { + "label": "Show text label in bar", + "description": "Displays 'Commands' next to the icon in the top bar" + } + }, + "notify": { + "success": "Command '{name}' completed successfully.", + "error": "Command '{name}' failed (Exit Code {code})." + } +} diff --git a/cmd-runner/widget.luau b/cmd-runner/widget.luau new file mode 100644 index 00000000..155bc03f --- /dev/null +++ b/cmd-runner/widget.luau @@ -0,0 +1,60 @@ +--!nonstrict +-- Bar widget entry for Command Runner plugin + +local PANEL_ID = "nocode-96/cmd-runner:panel" +local state = noctalia.state.get("cmd_runner_state") or { runningMap = {} } + +local function isAnyRunning() + if type(state) == "table" and type(state.runningMap) == "table" then + for _, running in pairs(state.runningMap) do + if running == true then return true end + end + end + return false +end + +local function render() + local running = isAnyRunning() + local showLabel = noctalia.getConfig("show_label") ~= false + + local children = { + ui.glyph({ + name = running and "player-play" or "terminal", + size = 16, + color = running and "primary" or "on_surface", + }), + } + + if showLabel or running then + table.insert(children, ui.label({ + text = running and noctalia.tr("panel.running") or noctalia.tr("widget.label"), + fontWeight = "bold", + color = running and "primary" or "on_surface", + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 6, align = "center" }, children)) + barWidget.setTooltip(noctalia.tr("widget.tooltip")) +end + +noctalia.state.watch("cmd_runner_state", function(val) + if type(val) == "table" then + state = val + render() + end +end) + +render() + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + noctalia.togglePanel(PANEL_ID) +end