Skip to content
Merged
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
79 changes: 70 additions & 9 deletions git_companion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,31 @@ your open PR/MR and issue counts. Click it to open the panel.
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.
**Panel** — the header carries a **refresh** button that re-fetches immediately
and a **close** button. Below it, a row shows your avatar, your username and a
subtitle (your `group` if set, otherwise your `repo`, otherwise your profile bio),
with the **last-update** time on the right. The avatar and username appear once
the CLI resolves your account; the last-update time is always shown.

Then up to three tabs, each listing items by title and reference:

- **Pull Requests / Merge Requests** — the ones you authored.
- **Issues** — the ones assigned to you.
- **Code Review** — open, unmerged PR/MRs waiting on your review.

On GitLab a check glyph marks a merge request whose `detailed_merge_status` is
`mergeable`. Click an item to open it on the web (GitHub or GitLab) in your
browser; the panel closes as it opens.

Each tab can be hidden with its `show_*` setting, and a hidden tab is not
fetched at all. Hide every tab and the panel says so instead of showing an empty
list.

**Branch badges** — a coloured chip in front of the reference of a PR/MR shows
the branch it targets, so a merge request into `develop` reads differently
from one into `release/18.3.4` at a glance. Issues have no target branch and
never carry one. The chip requires a provider that reports the target branch,
which **GitHub does not** — see Notes.

## Settings

Expand All @@ -45,9 +66,35 @@ header to re-fetch immediately. The last-updated time is shown next to the tabs.
| `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`. |
| `show_prs` | `bool` | `true` | Show the PRs/MRs tab. |
| `show_issues` | `bool` | `true` | Show the Issues tab. |
| `show_reviews` | `bool` | `true` | Show the Code Review tab. |
| `count_reviews_with_prs` | `bool` | `false` | Add the review count to the PR/MR number in the bar. Hidden while `show_reviews` is off. |
| `branch_badge_primary` | `string_list` | `["develop"]` | Branch patterns badged in the theme's primary accent. |
| `branch_badge_secondary` | `string_list` | `["release/*"]` | Branch patterns badged in the secondary accent. |
| `branch_badge_tertiary` | `string_list` | `[]` | Branch patterns badged in the tertiary accent. |
| `branch_badge_alert` | `string_list` | `["master", "main"]` | Branch patterns badged in the theme's alert colour. |

The four `branch_badge_*` lists decide the colour of the branch chip. Put one
branch pattern per entry: either an exact branch name (`develop`) or a prefix
ending in `*` (`release/*` matches `release/18.3.4`). The lists are checked in
the order above and **the first match wins**, so a branch listed under two
colours takes the earlier one. A branch matching nothing gets no chip.

The colour is the setting you put the branch in, so there is no colour to type.
Adding a fifth colour requires a plugin change rather than configuration. The
chips only appear for providers that report a target branch, which excludes
GitHub — see Notes.

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**.
to you**. On GitLab it lists merge requests and issues **authored by you** — both
GitLab lists are filtered by author, not by assignee. The Code Review tab is
filtered by **requested reviewer** on both platforms, and includes drafts.

`show_reviews` is the master switch for the Code Review feature: turned off, the
tab disappears, nothing is fetched for it, and `count_reviews_with_prs` has no
effect. The bar tooltip always lists issues, PRs/MRs and reviews separately, so
a folded count is never ambiguous.

## IPC

Expand All @@ -63,10 +110,24 @@ fetch completes.

- **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.
`group` setting. One call per visible tab, so hiding a tab removes its request.
The Code Review tab uses `glab mr list --reviewer=@me` or
`gh search prs --review-requested=@me`, which **both** platforms support —
unlike the branch badge below.
- **Branch badges are not available on GitHub.** The chip needs each item's
target branch, which the provider has to report. `glab` returns it with every
merge request. `gh search prs` has no base-branch field at all, and it is the
only GitHub call that works across a whole owner, so pull requests fetched
from GitHub render without a chip. This is a per-provider capability rather
than a setting: the `branch_badge_*` lists stay configured and apply to any
provider that does report a target branch.
- **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.
- **Filesystem:** the service downloads your profile picture to the plugin's own
data directory, at `~/.local/state/noctalia/plugins/data/tphilippot/git_companion/avatar`
(it follows `NOCTALIA_STATE_HOME` if you set it), so the panel can show it next
to your username. It is re-downloaded only when the avatar URL changes, and the
file persists until you delete it. Nothing else is written: the PR/MR and issue
lists are in-memory only and are 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.
service shows an error notification if neither is set.
163 changes: 143 additions & 20 deletions git_companion/panel.luau
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@
local snapshot = {
prs = noctalia.state.get("prs") or {},
issues = noctalia.state.get("issues") or {},
reviews = noctalia.state.get("reviews") or {},
prsCount = noctalia.state.get("prs_count") or 0,
issuesCount = noctalia.state.get("issues_count") or 0,
reviewsCount = noctalia.state.get("reviews_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 currentTab = "prs" -- a TABS id
local hoveredKey = nil

local render
Expand All @@ -36,8 +38,80 @@ local function itemsForTab()
return type(value) == "table" and value or {}
end

local TABS = {
{ id = "prs", setting = "show_prs", glyph = "git-pull-request" },
{ id = "issues", setting = "show_issues", glyph = "info-circle" },
{ id = "reviews", setting = "show_reviews", glyph = "eye" },
}

local function visibleTabs()
local shown = {}
for _, tab in ipairs(TABS) do
if noctalia.getConfig(tab.setting) ~= false then table.insert(shown, tab) end
end
return shown
end

local function resolveTab(shown)
for _, tab in ipairs(shown) do
if tab.id == currentTab then return tab end
end
return shown[1]
end

local function itemGlyph()
return currentTab == "prs" and "git-pull-request" or "info-circle"
for _, tab in ipairs(TABS) do
if tab.id == currentTab then return tab.glyph end
end
return "info-circle"
end

local BADGE_BUCKETS = {
{ key = "branch_badge_primary", color = "primary" },
{ key = "branch_badge_secondary", color = "secondary" },
{ key = "branch_badge_tertiary", color = "tertiary" },
{ key = "branch_badge_alert", color = "error" },
}

local function matchesBranch(branch, pattern)
local prefix = pattern:match("^(.*)%*$")
if prefix then return branch:sub(1, #prefix) == prefix end
return branch == pattern
end

-- First bucket holding a matching pattern wins. A branch listed under two
-- colours is the user's call, so there is nothing to arbitrate here.
local function branchColor(branch)
if not branch or branch == "" then return nil end
for _, bucket in ipairs(BADGE_BUCKETS) do
local patterns = noctalia.getConfig(bucket.key)
if type(patterns) == "table" then
for _, pattern in ipairs(patterns) do
if type(pattern) == "string" then
local trimmed = noctalia.string.trim(pattern)
if trimmed ~= "" and matchesBranch(branch, trimmed) then
return bucket.color
end
end
end
end
end
return nil
end

local function branchBadge(branch, color, hovered)
local textColor = hovered and "on_tertiary" or color
return ui.row({
fill = textColor .. "/0.18",
border = textColor .. "/0.32",
borderWidth = 1,
radius = 8,
paddingH = 7,
paddingV = 2,
align = "center",
}, {
ui.label({ text = branch, fontSize = 10, fontWeight = "medium", color = textColor }),
})
end

local function statusBox(glyph, message)
Expand All @@ -63,7 +137,7 @@ end

local function hasBlockingStatus()
local status = snapshot.status
return status ~= nil and status ~= "ok" and status ~= "loading"
return status ~= nil and status ~= "ok"
end

local function getSubtitle(group, repo, bio)
Expand Down Expand Up @@ -95,22 +169,44 @@ local function userRow()
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 }, {


local children = {}
if snapshot.username ~= "" then
table.insert(children, avatar())
table.insert(children, 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 }),
}),
})
}))
else
table.insert(children, ui.spacer({ flexGrow = 1 }))
end
table.insert(children, 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 }),
}))
return ui.row({ gap = 10, align = "center" }, children)
end

local function itemCard(item)
local key = tostring(item.url or item.ref or item.title)
local hovered = hoveredKey == key

-- Second line: the branch badge, when the target branch matches a configured
-- pattern, then the reference. Issues carry no target branch, so they never
-- get a badge without an explicit check.
local subtitle = {}
local badgeColor = branchColor(item.targetBranch)
if badgeColor then
table.insert(subtitle, branchBadge(item.targetBranch, badgeColor, hovered))
end
table.insert(subtitle, ui.label({
text = item.ref or "",
fontSize = 11,
color = hovered and "on_tertiary" or "on_surface_variant",
maxLines = 1,
}))

return ui.column({
key = key,
gap = 8,
Expand All @@ -128,7 +224,7 @@ local function itemCard(item)
ui.row({ gap = 10, align = "center" }, {
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 }),
ui.row({ gap = 6, align = "center" }, subtitle),
}),
ui.glyph({ name = "circle-check", size = 24, color = hovered and "on_tertiary" or "primary", visible = item.detailed_merge_status == "mergeable" }),
}),
Expand All @@ -147,6 +243,24 @@ local function itemList()
return rows
end

-- Only the PR/MR label depends on the platform, so labels stay per-tab.
local function tabLabel(tab)
if tab.id == "prs" then
local platform = noctalia.getConfig("platform")
local requestLabel = platform == "gitlab" and tr("merge_requests") or tr("pull_requests")
return tr("tab_prs", { label = requestLabel, count = snapshot.prsCount })
elseif tab.id == "issues" then
return tr("tab_issues", { count = snapshot.issuesCount })
end
return tr("tab_reviews", { count = snapshot.reviewsCount })
end

local TAB_ACTIONS = {
prs = "onTabPrs",
issues = "onTabIssues",
reviews = "onTabReviews",
}

local function tabButton(label, tab, callback)
return ui.button({
text = label,
Expand All @@ -164,23 +278,29 @@ end

render = function()
local platform = noctalia.getConfig("platform")
local requestLabel = platform == "gitlab" and tr("merge_requests") or tr("pull_requests")
local blocked = hasBlockingStatus()
local shown = visibleTabs()
local active = resolveTab(shown)
if active then currentTab = active.id end

-- 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))
elseif not active then
-- Every tab turned off: nothing to select, so say so rather than render an
-- empty strip above an empty list.
table.insert(body, userRow())
table.insert(body, statusBox("eye-off", tr("no_tabs")))
else
if snapshot.username ~= "" then
table.insert(body, userRow())
table.insert(body, userRow())
local strip = {}
for _, tab in ipairs(shown) do
table.insert(strip, tabButton(tabLabel(tab), tab.id, TAB_ACTIONS[tab.id]))
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.row({ gap = 4, align = "center" }, strip))
table.insert(body, ui.scroll({ flexGrow = 1, gap = 8, align = "stretch", justify = "start" }, itemList()))
end

Expand All @@ -198,6 +318,7 @@ end

function onTabPrs() switchTab("prs") end
function onTabIssues() switchTab("issues") end
function onTabReviews() switchTab("reviews") end

function onCloseClicked() panel.close() end

Expand All @@ -208,8 +329,10 @@ 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("reviews", function(value) snapshot.reviews = 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("reviews_count", function(value) snapshot.reviewsCount = 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)
Expand Down
Loading
Loading