Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions git_companion/README.md
Original file line number Diff line number Diff line change
@@ -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.
224 changes: 224 additions & 0 deletions git_companion/panel.luau
Original file line number Diff line number Diff line change
@@ -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()
74 changes: 74 additions & 0 deletions git_companion/plugin.toml
Original file line number Diff line number Diff line change
@@ -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
Loading