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..eb6ceef8 --- /dev/null +++ b/git_companion/panel.luau @@ -0,0 +1,224 @@ +--!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 "", + 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 + +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 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) + 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 { statusBox(itemGlyph(), tr("no_items")) } + 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 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 + 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" }), + }), + table.unpack(body), + })) +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) +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) +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..55a231be --- /dev/null +++ b/git_companion/service.luau @@ -0,0 +1,165 @@ +-- 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 PLATFORM_GROUP = { + gitlab = "--group", + github = "--owner", +} + +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 group ~= "" then return {PLATFORM_GROUP[platform], shellQuote(group)} end + if repo ~= "" then return {"--repo", shellQuote(repo)} 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 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) + 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 00000000..9592128f Binary files /dev/null and b/git_companion/thumbnail.webp differ diff --git a/git_companion/translations/en.json b/git_companion/translations/en.json new file mode 100644 index 00000000..af660d30 --- /dev/null +++ b/git_companion/translations/en.json @@ -0,0 +1,57 @@ +{ + "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": "...", + "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", + "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