From bc95b167747c79460e44dc8209ecb78d4754a3ea Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 10 Aug 2026 23:05:34 +0200 Subject: [PATCH 1/3] Add git companion plugin --- git_companion/README.md | 72 ++++++++++++++ git_companion/panel.luau | 152 +++++++++++++++++++++++++++++ git_companion/plugin.toml | 74 ++++++++++++++ git_companion/service.luau | 129 ++++++++++++++++++++++++ git_companion/thumbnail.webp | Bin 0 -> 36538 bytes git_companion/translations/en.json | 54 ++++++++++ git_companion/widget.luau | 53 ++++++++++ 7 files changed, 534 insertions(+) create mode 100644 git_companion/README.md create mode 100644 git_companion/panel.luau create mode 100644 git_companion/plugin.toml create mode 100644 git_companion/service.luau create mode 100644 git_companion/thumbnail.webp create mode 100644 git_companion/translations/en.json create mode 100644 git_companion/widget.luau diff --git a/git_companion/README.md b/git_companion/README.md new file mode 100644 index 00000000..78f111f9 --- /dev/null +++ b/git_companion/README.md @@ -0,0 +1,72 @@ +# Git Companion + +Monitor your git repositories from the Noctalia bar. See your open pull requests +and issues at a glance, and jump to any of them on the web without leaving your +desktop. Works with **GitHub** (via `gh`) and **GitLab** (via `glab`). + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `tphilippot/git_companion` | +| Entries | Bar widget: `widget`; panel: `main`; service: `service` | + +## Requirements + +Install the CLI for the platform you want to monitor and sign in once: + +- `gh` — the [GitHub CLI](https://cli.github.com/), for `platform = github`. Run `gh auth login`. +- `glab` — the [GitLab CLI](https://glab.readthedocs.io/), for `platform = gitlab`. Run `glab auth login`. +- `xdg-open` — opens a PR/MR or issue in your default browser when you click it in the panel. + +You only need the CLI matching your chosen `platform`; both are listed so the +requirement shows up regardless of which one you pick. + +## Usage + +**Bar widget** — add the `widget` entry to your bar to see a platform glyph plus +your open PR/MR and issue counts. Click it to open the panel. + +```sh +noctalia msg panel-toggle tphilippot/git_companion:main +``` + +**Panel** — two tabs, **Pull Requests / Merge Requests** and **Issues**, each +listing your items. Click an item to open it on the web (GitHub or GitLab) in +your browser; the panel closes as it opens. Use the **refresh** button in the +header to re-fetch immediately. The last-updated time is shown next to the tabs. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `platform` | `select` | `github` | `github` (uses `gh`) or `gitlab` (uses `glab`). | +| `repo` | `string` | `""` | Scope to one repo: `owner/repo` (GitHub) or the project path (GitLab). Leave empty to use the group/owner. | +| `group` | `string` | `""` | Filter by group (GitLab) or owner (GitHub). Leave empty to use `repo`. | +| `refresh_interval` | `int` | `60` | Seconds between automatic refreshes (minimum 10). | +| `bar_display_mode` | `select` | `both` | What the bar shows next to the glyph: `none`, `prs` (PRs/MRs only), `issues` (Issues only), or `both`. | + +On GitHub the widget lists pull requests **authored by you** and issues **assigned +to you**; on GitLab it lists merge requests and issues **assigned to you**. + +## IPC + +```sh +noctalia msg plugin tphilippot/git_companion:service all refresh +``` + +Re-fetch PRs/MRs and issues immediately. The service is a singleton, so it is +addressed with the `all` target. Results land in the panel automatically when the +fetch completes. + +## Notes + +- **Network:** each refresh calls the GitHub or GitLab API through the installed + CLI (`gh search prs/issues` or `glab mr/issue list`), scoped by your `repo` or + `group` setting. +- **Processes:** the service spawns `gh` or `glab` for fetches; the panel spawns + `xdg-open` (non-blocking) when you click an item. +- **Filesystem:** nothing is written to disk — plugin state is in-memory only and + is cleared when the plugin stops. +- **GitLab scope:** GitLab requires a `repo` or `group` scope in settings; the + service shows an error notification if neither is set. \ No newline at end of file diff --git a/git_companion/panel.luau b/git_companion/panel.luau new file mode 100644 index 00000000..1b25a49f --- /dev/null +++ b/git_companion/panel.luau @@ -0,0 +1,152 @@ +--!nonstrict +-- Git Companion panel — a mini-docker-style dashboard for your PRs and issues. +-- Tabs switch between pull requests and issues; clicking an item opens it on +-- the web (GitHub/GitLab) via xdg-open. Layout mirrors mini-docker's panel: +-- tab buttons with selected/ghost variants, an outline button-card list, and +-- a centered empty state with a large glyph. + +local snapshot = { + prs = noctalia.state.get("prs") or {}, + issues = noctalia.state.get("issues") or {}, + prsCount = noctalia.state.get("prs_count") or 0, + issuesCount = noctalia.state.get("issues_count") or 0, + updatedAt = noctalia.state.get("last_update") or "", +} +local currentTab = "prs" -- "prs" or "issues" +local hoveredKey = nil + +local render + +local function tr(key, args) return noctalia.tr("panel." .. key, args) end + +-- Open a PR/issue URL in the default browser. Single quotes are escaped so the +-- URL can't break out of the shell argument. +local function openLink(link) + if not link or link == "" then return end + local safe = link:gsub("'", "'\\''") + noctalia.runAsync("xdg-open '" .. safe .. "'") +end + +local function itemsForTab() + local value = snapshot[currentTab] + return type(value) == "table" and value or {} +end + +local function itemGlyph() + return currentTab == "prs" and "git-pull-request" or "info-circle" +end + +local function emptyMessage() + return tr("no_items") +end + +local function itemCard(item) + local key = tostring(item.url or item.ref or item.title) + local hovered = hoveredKey == key + return ui.column({ + key = key, + gap = 8, + padding = 10, + radius = 8, + border = "outline", + borderWidth = 1, + fill = hovered and "tertiary" or "surface_variant", + onClick = function() openLink(item.url); panel.close() end, + onHover = function(state) + hoveredKey = (state == "true") and key or nil + render() + end, + }, { + ui.row({ gap = 10, align = "start" }, { + ui.glyph({ name = itemGlyph(), size = 16, color = hovered and "on_tertiary" or "primary" }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = item.title or tr("untitled"), color = hovered and "on_tertiary" or "on_surface", fontWeight = "bold", maxLines = 2 }), + ui.label({ text = item.ref or "", fontSize = 11, color = hovered and "on_tertiary" or "on_surface_variant", maxLines = 1 }), + }), + }), + }) +end + +local function itemList() + local items = itemsForTab() + if #items == 0 then + return { + ui.column({ align = "center", padding = 24 }, { + ui.glyph({ name = itemGlyph(), size = 42, color = "primary" }), + ui.label({ text = emptyMessage(), color = "on_surface", textAlign = "center" }), + }), + } + end + local rows = {} + for _, item in ipairs(items) do + table.insert(rows, itemCard(item)) + end + return rows +end + +local function tabButton(label, tab, callback) + return ui.button({ + text = label, + selected = currentTab == tab, + variant = currentTab == tab and "primary" or "ghost", + onClick = callback, + }) +end + +local function switchTab(tab) + currentTab = tab + hoveredKey = nil + render() +end + +render = function() + local platform = noctalia.getConfig("platform") + local requestLabel = platform == "gitlab" and tr("merge_requests") or tr("pull_requests") + local updatedAt = snapshot.updatedAt + + panel.render(ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, { + -- Header + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = platform == "gitlab" and "brand-gitlab" or "brand-github", size = 24, color = "primary" }), + ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefreshClicked" }), + ui.button({ glyph = "close", onClick = "onCloseClicked" }), + }), + -- Tabs (mini-docker style: selected = primary, else ghost) + last-updated time + ui.row({ gap = 4, align = "center" }, { + tabButton(tr("tab_prs", { label = requestLabel, count = snapshot.prsCount }), "prs", "onTabPrs"), + tabButton(tr("tab_issues", { count = snapshot.issuesCount }), "issues", "onTabIssues"), + ui.spacer({ flexGrow = 1 }), + ui.label({ + text = updatedAt ~= "" and updatedAt or tr("last_update_placeholder"), + color = "on_surface_variant", + fontSize = 11, + }), + }), + -- List + ui.scroll({ flexGrow = 1, gap = 8, align = "stretch", justify = "start" }, itemList()), + })) +end + +function onTabPrs() switchTab("prs") end +function onTabIssues() switchTab("issues") end + +function onCloseClicked() panel.close() end + +function onRefreshClicked() + noctalia.runAsync("noctalia msg plugin tphilippot/git_companion:service all refresh") +end + +-- Watch for state changes from the service +noctalia.state.watch("prs", function(value) snapshot.prs = value or {}; render() end) +noctalia.state.watch("issues", function(value) snapshot.issues = value or {}; render() end) +noctalia.state.watch("prs_count", function(value) snapshot.prsCount = value or 0; render() end) +noctalia.state.watch("issues_count", function(value) snapshot.issuesCount = value or 0; render() end) +noctalia.state.watch("last_update", function(value) snapshot.updatedAt = value or ""; render() end) + +-- Periodically re-render to pick up setting changes (like platform) +noctalia.setUpdateInterval(5000) +function update() render() end + +-- Initial render +render() diff --git a/git_companion/plugin.toml b/git_companion/plugin.toml new file mode 100644 index 00000000..cf22aaf3 --- /dev/null +++ b/git_companion/plugin.toml @@ -0,0 +1,74 @@ +id = "tphilippot/git_companion" +name = "Git Companion" +version = "1.0.0" +plugin_api = 23 +author = "thomas-philippot" +license = "MIT" +icon = "brand-git" +description = "Monitor your git repositories from the Noctalia bar. See your current PRs/MRs and issues at a glance." +tags = ["development", "bar", "panel", "service", "productivity"] +dependencies = ["gh", "glab", "xdg-open"] + +# ── Plugin-level settings ──────────────────────────────────── + +[[setting]] +key = "platform" +type = "select" +label_key = "settings.platform.label" +description_key = "settings.platform.description" +default = "github" +options = [ + { value = "github", label_key = "settings.platform.options.github" }, + { value = "gitlab", label_key = "settings.platform.options.gitlab" } +] + +[[setting]] +key = "repo" +type = "string" +label_key = "settings.repo.label" +description_key = "settings.repo.description" +default = "" + +[[setting]] +key = "group" +type = "string" +label_key = "settings.group.label" +description_key = "settings.group.description" +default = "" + +[[setting]] +key = "refresh_interval" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 60 +min = 10 + +[[setting]] +key = "bar_display_mode" +type = "select" +label_key = "settings.bar_display_mode.label" +description_key = "settings.bar_display_mode.description" +default = "both" +options = [ + { value = "none", label_key = "settings.bar_display_mode.options.none" }, + { value = "prs", label_key = "settings.bar_display_mode.options.prs" }, + { value = "issues", label_key = "settings.bar_display_mode.options.issues" }, + { value = "both", label_key = "settings.bar_display_mode.options.both" } +] + +# ── Entryfiles ──────────────────────────────────── + +[[service]] +id = "service" +entry = "service.luau" + +[[widget]] +id = "widget" +entry = "widget.luau" + +[[panel]] +id = "main" +entry = "panel.luau" +placement = "attached" +open_near_click = true diff --git a/git_companion/service.luau b/git_companion/service.luau new file mode 100644 index 00000000..3f13024d --- /dev/null +++ b/git_companion/service.luau @@ -0,0 +1,129 @@ +-- Git Companion Service +-- Headless entry: fetches data from gh/glab and stores it in noctalia.state. + +local platform = noctalia.getConfig("platform") or "github" +local repo = noctalia.getConfig("repo") or "" +local group = noctalia.getConfig("group") or "" +local refreshInterval = noctalia.getConfig("refresh_interval") or 60 + +local bin = platform == "gitlab" and "glab" or "gh" + +local function tr(key, args) return noctalia.tr("service." .. key, args) end + +-- Set the refresh interval +noctalia.setUpdateInterval(refreshInterval * 1000) + +-- Determine scope flags +local function scopeArgs() + if platform == "gitlab" then + if group ~= "" then return {"--group", group} end + if repo ~= "" then return {"--repo", repo} end + else + if group ~= "" then return {"--owner", group} end + if repo ~= "" then return {"--repo", repo} end + end + return {} +end + +local function buildCommand(kind) + local parts = {} + if platform == "gitlab" then + local sub = kind == "pr" and "mr" or "issue" + table.insert(parts, bin) + table.insert(parts, sub) + table.insert(parts, "list") + for _, v in ipairs(scopeArgs()) do table.insert(parts, v) end + table.insert(parts, "--assignee=@me") + table.insert(parts, "--output") + table.insert(parts, "json") + else + -- GitHub + local sub = kind == "pr" and "prs" or "issues" + local filter = kind == "pr" and "--author=@me" or "--assignee=@me" + table.insert(parts, bin) + table.insert(parts, "search") + table.insert(parts, sub) + table.insert(parts, filter) + table.insert(parts, "--state=open") + table.insert(parts, "--json") + table.insert(parts, "number,title,url,repository") + table.insert(parts, "--limit") + table.insert(parts, "15") + for _, v in ipairs(scopeArgs()) do table.insert(parts, v) end + end + return table.concat(parts, " ") +end + +local function normalizeItem(raw, kind) + if platform == "gitlab" then + local prefix = kind == "pr" and "!" or "#" + return { + title = raw.title or "", + url = raw.web_url or "", + ref = (raw.references and raw.references.full) or (prefix .. (raw.iid or "")) + } + end + -- GitHub + local repoName = (raw.repository and (raw.repository.nameWithOwner or raw.repository.name)) or "" + return { + title = raw.title or "", + url = raw.url or "", + ref = repoName .. "#" .. (raw.number or "") + } +end + +local function processList(kind, output) + if not output or output == "" then return {} end + local decoded = noctalia.json.decode(output) + if type(decoded) ~= "table" then return {} end + + local list = {} + for _, raw in ipairs(decoded) do + table.insert(list, normalizeItem(raw, kind)) + end + return list +end + +local function fetchList(kind) + local cmd = buildCommand(kind) + noctalia.log("[Git Companion] Running: " .. cmd) + noctalia.runAsync(cmd, function(res) + local list = processList(kind, res.stdout) + noctalia.state.set(kind .. "s", list) -- "issues" or "prs" + noctalia.state.set(kind .. "s_count", #list) + end) +end + +local function refresh() + noctalia.log("[Git Companion] Refresh triggered.") + if not noctalia.commandExists(bin) then + noctalia.notifyError(tr("title"), tr("bin_missing", { bin = bin })) + noctalia.log("[Git Companion] Error: " .. bin .. " not found.") + return + end + + if platform == "gitlab" and repo == "" and group == "" then + noctalia.notifyError(tr("title"), tr("gitlab_scope_missing")) + noctalia.log("[Git Companion] Error: GitLab scope missing.") + return + end + + noctalia.state.set("loading", true) + fetchList("issue") + fetchList("pr") + noctalia.state.set("loading", false) + noctalia.state.set("last_update", noctalia.formatTime("%H:%M:%S")) +end + +-- Initial fetch +refresh() + +function update() + refresh() +end + +function onIpc(event, payload) + if event == "refresh" then + refresh() + end +end diff --git a/git_companion/thumbnail.webp b/git_companion/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..9592128fd4dc1a4b942349de3601e3c67edce7a4 GIT binary patch literal 36538 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 CZ48zG literal 0 HcmV?d00001 diff --git a/git_companion/translations/en.json b/git_companion/translations/en.json new file mode 100644 index 00000000..2b2465a7 --- /dev/null +++ b/git_companion/translations/en.json @@ -0,0 +1,54 @@ +{ + "widget": { + "prs": "PRs", + "mrs": "MRs", + "tooltip": "{issues} Issues, {prs} {label}" + }, + "panel": { + "title": "Git Companion", + "pull_requests": "Pull Requests", + "merge_requests": "Merge Requests", + "tab_prs": "{label} ({count})", + "tab_issues": "Issues ({count})", + "no_items": "No items", + "untitled": "Untitled", + "last_update_placeholder": "..." + }, + "service": { + "title": "Git Companion", + "bin_missing": "{bin} is not installed.", + "gitlab_scope_missing": "GitLab requires a repo or group scope in settings." + }, + "settings": { + "platform": { + "label": "Platform", + "description": "Choose between GitHub or GitLab.", + "options": { + "github": "GitHub", + "gitlab": "GitLab" + } + }, + "repo": { + "label": "Repository", + "description": "e.g. owner/repo (GitHub) or project path (GitLab). Leave empty to use group/owner." + }, + "group": { + "label": "Group / Owner", + "description": "Filter by group (GitLab) or owner (GitHub). Leave empty to use repo." + }, + "refresh_interval": { + "label": "Refresh Interval (seconds)", + "description": "How often to fetch updates from the API." + }, + "bar_display_mode": { + "label": "Bar Display Mode", + "description": "Choose what text to show next to the icon in the bar.", + "options": { + "none": "Nothing", + "prs": "PRs/MRs only", + "issues": "Issues only", + "both": "Both" + } + } + } +} \ No newline at end of file diff --git a/git_companion/widget.luau b/git_companion/widget.luau new file mode 100644 index 00000000..b3d2287d --- /dev/null +++ b/git_companion/widget.luau @@ -0,0 +1,53 @@ +-- Git Companion Bar Widget +-- Shows the platform icon and counts of issues/PRs from the service state. +-- This is purely an indicator; the data is displayed on the Desktop Widget. + +local function tr(key, args) return noctalia.tr("widget." .. key, args) end + +local function getPlatformIcon() + local platform = noctalia.getConfig("platform") + if platform == "gitlab" then + return "brand-gitlab" + elseif platform == "github" then + return "brand-github" + end + return "brand-git" -- Fallback +end + +local function render() + local issuesCount = noctalia.state.get("issues_count") or 0 + local prsCount = noctalia.state.get("prs_count") or 0 + local platform = noctalia.getConfig("platform") + local requestLabel = platform == "gitlab" and tr("mrs") or tr("prs") + local mode = noctalia.getConfig("bar_display_mode") or "both" + + -- Format text based on display mode + local text = "" + if mode == "both" then + if issuesCount > 0 or prsCount > 0 then + text = string.format("%d/%d", issuesCount, prsCount) + end + elseif mode == "prs" then + if prsCount > 0 then text = tostring(prsCount) end + elseif mode == "issues" then + if issuesCount > 0 then text = tostring(issuesCount) end + end + -- mode == "none" leaves text as "" + + barWidget.setGlyph(getPlatformIcon()) + barWidget.setText(text) + + local tooltip = tr("tooltip", { issues = issuesCount, prs = prsCount, label = requestLabel }) + barWidget.setTooltip(tooltip) +end + +-- Initial render +render() + +-- Watch for state changes from the service +noctalia.state.watch("issues_count", render) +noctalia.state.watch("prs_count", render) + +function onClick() + noctalia.togglePanel("tphilippot/git_companion:main") +end From 76db6d4406f636eba4fdd86626159847295ba3b3 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 11 Aug 2026 11:15:00 +0200 Subject: [PATCH 2/3] add shellQuote function --- git_companion/service.luau | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/git_companion/service.luau b/git_companion/service.luau index 3f13024d..26c6f351 100644 --- a/git_companion/service.luau +++ b/git_companion/service.luau @@ -13,14 +13,18 @@ local function tr(key, args) return noctalia.tr("service." .. key, args) end -- Set the refresh interval noctalia.setUpdateInterval(refreshInterval * 1000) +local function shellQuote(value) + return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'" +end + -- Determine scope flags local function scopeArgs() if platform == "gitlab" then - if group ~= "" then return {"--group", group} end - if repo ~= "" then return {"--repo", repo} end + if group ~= "" then return {"--group", shellQuote(group)} end + if repo ~= "" then return {"--repo", shellQuote(repo)} end else - if group ~= "" then return {"--owner", group} end - if repo ~= "" then return {"--repo", repo} end + if group ~= "" then return {"--owner", shellQuote(group)} end + if repo ~= "" then return {"--repo", shellQuote(repo)} end end return {} end From 8f788e5e47c6f245003fc2e70bfc1161c125bd84 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 12 Aug 2026 19:27:53 +0200 Subject: [PATCH 3/3] add user information row --- git_companion/panel.luau | 116 +++++++++++++++++++++++------ git_companion/service.luau | 46 ++++++++++-- git_companion/translations/en.json | 5 +- 3 files changed, 137 insertions(+), 30 deletions(-) diff --git a/git_companion/panel.luau b/git_companion/panel.luau index 1b25a49f..eb6ceef8 100644 --- a/git_companion/panel.luau +++ b/git_companion/panel.luau @@ -11,6 +11,10 @@ local snapshot = { prsCount = noctalia.state.get("prs_count") or 0, issuesCount = noctalia.state.get("issues_count") or 0, updatedAt = noctalia.state.get("last_update") or "", + status = noctalia.state.get("status") or "ok", + username = noctalia.state.get("username") or "", + bio = noctalia.state.get("bio") or "", + avatarPath = noctalia.state.get("avatar_path") or "", } local currentTab = "prs" -- "prs" or "issues" local hoveredKey = nil @@ -36,8 +40,72 @@ local function itemGlyph() return currentTab == "prs" and "git-pull-request" or "info-circle" end -local function emptyMessage() - return tr("no_items") +local function statusBox(glyph, message) + return ui.column({ flexGrow = 1, gap = 12, align = "center", justify = "center", padding = 24 }, { + ui.glyph({ name = glyph, size = 42, color = "on_surface_variant" }), + ui.label({ text = message, color = "on_surface_variant", textAlign = "center", maxLines = 4 }), + }) +end + +local function statusInfo() + local platform = noctalia.getConfig("platform") + local bin = platform == "gitlab" and "glab" or "gh" + local status = snapshot.status + if status == "bin_missing" then + return platform == "gitlab" and "brand-gitlab" or "brand-github", + tr("bin_missing", { bin = bin }) + end + if status == "gitlab_scope_missing" then + return "alert-triangle", tr("gitlab_scope_missing") + end + return "alert-triangle", tr("no_items") +end + +local function hasBlockingStatus() + local status = snapshot.status + return status ~= nil and status ~= "ok" and status ~= "loading" +end + +local function getSubtitle(group, repo, bio) + if group ~= "" then return group end + if repo ~= "" then return repo end + if bio ~= "" then return bio end + return nil +end + +local function avatar() + if snapshot.avatarPath ~= "" then + return ui.image({ + path = snapshot.avatarPath, + width = 40, height = 40, radius = 20, + fit = "cover", border = "primary", borderWidth = 2, + }) + end + return ui.column({ + width = 40, height = 40, radius = 20, + border = "primary", borderWidth = 2, + align = "center", justify = "center", + }, { + ui.glyph({ name = "user", size = 24, color = "on_surface_variant" }), + }) +end + +local function userRow() + local group = noctalia.getConfig("group") or "" + local repo = noctalia.getConfig("repo") or "" + local subtitle = getSubtitle(group, repo, snapshot.bio) + local updatedAt = snapshot.updatedAt + return ui.row({ gap = 10, align = "center" }, { + avatar(), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = snapshot.username, fontWeight = "bold", color = "on_surface", maxLines = 1 }), + ui.label({ text = subtitle, fontWeight = "thin", color = "on_surface", fontSize = 11, maxLines = 1 }), + }), + ui.column({ gap = 2, align = "end" }, { + ui.label({ text = tr("last_update"), color = "on_surface_variant", fontSize = 10 }), + ui.label({ text = updatedAt ~= "" and updatedAt or tr("last_update_placeholder"), color = "on_surface", fontSize = 11 }), + }), + }) end local function itemCard(item) @@ -70,12 +138,7 @@ end local function itemList() local items = itemsForTab() if #items == 0 then - return { - ui.column({ align = "center", padding = 24 }, { - ui.glyph({ name = itemGlyph(), size = 42, color = "primary" }), - ui.label({ text = emptyMessage(), color = "on_surface", textAlign = "center" }), - }), - } + return { statusBox(itemGlyph(), tr("no_items")) } end local rows = {} for _, item in ipairs(items) do @@ -102,7 +165,24 @@ end render = function() local platform = noctalia.getConfig("platform") local requestLabel = platform == "gitlab" and tr("merge_requests") or tr("pull_requests") - local updatedAt = snapshot.updatedAt + local blocked = hasBlockingStatus() + + -- Body: the tabbed list when healthy, a centered status box otherwise. + -- The tab row is hidden in the blocking state so the card collapses. + local body = {} + if blocked then + local glyph, message = statusInfo() + table.insert(body, statusBox(glyph, message)) + else + if snapshot.username ~= "" then + table.insert(body, userRow()) + end + table.insert(body, ui.row({ gap = 4, align = "center" }, { + tabButton(tr("tab_prs", { label = requestLabel, count = snapshot.prsCount }), "prs", "onTabPrs"), + tabButton(tr("tab_issues", { count = snapshot.issuesCount }), "issues", "onTabIssues"), + })) + table.insert(body, ui.scroll({ flexGrow = 1, gap = 8, align = "stretch", justify = "start" }, itemList())) + end panel.render(ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, { -- Header @@ -112,19 +192,7 @@ render = function() ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefreshClicked" }), ui.button({ glyph = "close", onClick = "onCloseClicked" }), }), - -- Tabs (mini-docker style: selected = primary, else ghost) + last-updated time - ui.row({ gap = 4, align = "center" }, { - tabButton(tr("tab_prs", { label = requestLabel, count = snapshot.prsCount }), "prs", "onTabPrs"), - tabButton(tr("tab_issues", { count = snapshot.issuesCount }), "issues", "onTabIssues"), - ui.spacer({ flexGrow = 1 }), - ui.label({ - text = updatedAt ~= "" and updatedAt or tr("last_update_placeholder"), - color = "on_surface_variant", - fontSize = 11, - }), - }), - -- List - ui.scroll({ flexGrow = 1, gap = 8, align = "stretch", justify = "start" }, itemList()), + table.unpack(body), })) end @@ -143,6 +211,10 @@ noctalia.state.watch("issues", function(value) snapshot.issues = value or {}; re noctalia.state.watch("prs_count", function(value) snapshot.prsCount = value or 0; render() end) noctalia.state.watch("issues_count", function(value) snapshot.issuesCount = value or 0; render() end) noctalia.state.watch("last_update", function(value) snapshot.updatedAt = value or ""; render() end) +noctalia.state.watch("status", function(value) snapshot.status = value or "ok"; render() end) +noctalia.state.watch("username", function(value) snapshot.username = value or ""; render() end) +noctalia.state.watch("bio", function(value) snapshot.bio = value or ""; render() end) +noctalia.state.watch("avatar_path", function(value) snapshot.avatarPath = value or ""; render() end) -- Periodically re-render to pick up setting changes (like platform) noctalia.setUpdateInterval(5000) diff --git a/git_companion/service.luau b/git_companion/service.luau index 26c6f351..55a231be 100644 --- a/git_companion/service.luau +++ b/git_companion/service.luau @@ -7,6 +7,10 @@ local group = noctalia.getConfig("group") or "" local refreshInterval = noctalia.getConfig("refresh_interval") or 60 local bin = platform == "gitlab" and "glab" or "gh" +local PLATFORM_GROUP = { + gitlab = "--group", + github = "--owner", +} local function tr(key, args) return noctalia.tr("service." .. key, args) end @@ -19,13 +23,8 @@ end -- Determine scope flags local function scopeArgs() - if platform == "gitlab" then - if group ~= "" then return {"--group", shellQuote(group)} end - if repo ~= "" then return {"--repo", shellQuote(repo)} end - else - if group ~= "" then return {"--owner", shellQuote(group)} end - if repo ~= "" then return {"--repo", shellQuote(repo)} end - end + if group ~= "" then return {PLATFORM_GROUP[platform], shellQuote(group)} end + if repo ~= "" then return {"--repo", shellQuote(repo)} end return {} end @@ -98,21 +97,54 @@ local function fetchList(kind) end) end +local lastAvatarUrl = "" + +local function fetchUser() + noctalia.runAsync(bin .. " api user", function(res) + if res.exitCode ~= 0 then + noctalia.log("[Git Companion] User fetch failed: " .. (res.stderr or "")) + return + end + local decoded = noctalia.json.decode(res.stdout or "") + if type(decoded) ~= "table" then return end + local username = decoded.username or decoded.login or "" + local bio = decoded.bio or "" + local avatarUrl = decoded.avatar_url or "" + noctalia.state.set("username", username) + noctalia.state.set("bio", bio) + if avatarUrl ~= "" and avatarUrl ~= lastAvatarUrl then + lastAvatarUrl = avatarUrl + local dir = noctalia.pluginDataDir() + if not dir then return end + local dest = dir .. "/avatar" + noctalia.download(avatarUrl, dest, function(ok) + if ok then noctalia.state.set("avatar_path", dest) end + end) + end + end) +end + local function refresh() noctalia.log("[Git Companion] Refresh triggered.") if not noctalia.commandExists(bin) then + noctalia.state.set("status", "bin_missing") noctalia.notifyError(tr("title"), tr("bin_missing", { bin = bin })) noctalia.log("[Git Companion] Error: " .. bin .. " not found.") return end if platform == "gitlab" and repo == "" and group == "" then + noctalia.state.set("status", "gitlab_scope_missing") noctalia.notifyError(tr("title"), tr("gitlab_scope_missing")) noctalia.log("[Git Companion] Error: GitLab scope missing.") return end + -- All preconditions met: the panel can render the lists. Individual fetch + -- failures are surfaced per-list (see fetchList), not by flipping this. + noctalia.state.set("status", "ok") noctalia.state.set("loading", true) + fetchUser() fetchList("issue") fetchList("pr") noctalia.state.set("loading", false) diff --git a/git_companion/translations/en.json b/git_companion/translations/en.json index 2b2465a7..af660d30 100644 --- a/git_companion/translations/en.json +++ b/git_companion/translations/en.json @@ -12,7 +12,10 @@ "tab_issues": "Issues ({count})", "no_items": "No items", "untitled": "Untitled", - "last_update_placeholder": "..." + "last_update_placeholder": "...", + "last_update": "Last update", + "bin_missing": "{bin} is not installed. Install it to use this plugin.", + "gitlab_scope_missing": "Set a Repository or Group in settings to view your items." }, "service": { "title": "Git Companion",