feat(forge): inline overlay rendering for forge links (#127)

* docs: document S3 backend, auto-auth, and `:Pending done` command

Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS
section, `:Pending auth` only mentioned Google, the `sync` config
field omitted `s3`, `_s3_sync_id` was missing from the data format
section, `:Pending done` was implemented but undocumented, and the
README lacked a features overview.

Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite
`:Pending auth` to cover all backends and sub-actions, update config
and data format references, add `aws` CLI to requirements, and add a
Features section to `README.md`.

* feat(forge): add forge link parser and metadata fetcher

Problem: no way to associate tasks with GitHub, GitLab, or Codeberg
issues/PRs, or to track their remote state.

Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full
URL parsing, async metadata fetching via `curl`, label formatting,
conceal pattern generation, token resolution, and `refresh()` for
state pull (closed/merged -> done).

* feat(config): add forge config defaults and `%l` eol specifier

Problem: no configuration surface for forge link rendering, icons,
issue format, or self-hosted instances.

Solution: add `pending.ForgeConfig` class with per-forge `token`,
`icon`, `issue_format`, and `instances` fields. Add `%l` to the
default `eol_format` so forge labels render in virtual text.

* feat(parse): extract forge refs from task body

Problem: `parse.body()` had no awareness of forge link tokens, so
`gh:user/repo#42` stayed in the description instead of metadata.

Solution: add `forge_ref` field to `pending.Metadata` and extend the
right-to-left token loop in `body()` to call `forge.parse_ref()` as
the final fallback before breaking.

* feat(diff): persist forge refs in store on write

Problem: forge refs parsed from buffer lines were discarded during
diff reconciliation and never stored in the JSON.

Solution: thread `forge_ref` through `parse_buffer` entries into
`diff.apply`, storing it in `task._extra._forge_ref` for both new
and existing tasks.

* feat(views): pass forge ref and cache to line metadata

Problem: `LineMeta` had no forge fields, so `buffer.lua` could not
render forge labels or apply forge-specific highlights.

Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`,
populated from `task._extra` in both `category_view` and
`priority_view`.

* feat(buffer): render forge links as concealed text with eol virt text

Problem: forge tokens were visible as raw text with no virtual text
labels, and the eol separator logic collapsed all gaps when
non-adjacent specifiers were absent.

Solution: add forge conceal syntax patterns in `setup_syntax()`, add
`PendingForge`/`PendingForgeClosed` highlight groups, handle the
`%l` specifier in `build_eol_virt()`, fix separator collapsing to
buffer one separator between present segments, and change
`concealcursor` to `nc` (reveal in visual and insert mode).

* feat(complete): add forge shorthand omnifunc completions

Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens,
requiring users to type owner/repo from memory.

Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and
complete with `owner/repo#` candidates from existing forge refs in
the store.

* feat: trigger forge refresh on buffer open

Problem: forge metadata was never fetched, so virt text highlights
could not reflect remote issue/PR state.

Solution: call `forge.refresh()` in `M.open()` so metadata is
fetched once per `:Pending` invocation rather than on every render.

* test(forge): add forge parsing spec

Problem: no test coverage for forge link shorthand parsing, URL
parsing, label formatting, or API URL generation.

Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`,
`parse_ref` for all three forges, full URL parsing including nested
GitLab groups, `format_label`, and `_api_url`.

* docs: document forge links feature

Problem: no user-facing documentation for forge link syntax,
configuration, or behavior.

Solution: add forge links section to `README.md` and `pending.txt`
covering shorthand/URL syntax, config options, virtual text
rendering, state pull, and auth resolution.

* feat(forge): add `find_refs()` inline token scanner

Problem: forge tokens were extracted by `parse.body()` which stripped
them from the description, making editing awkward and multi-ref lines
impossible.

Solution: add `find_refs(text)` that scans a string for all forge
tokens by whitespace tokenization, returning byte offsets and parsed
refs without modifying the input. Remove unused `conceal_patterns()`.

* refactor: move forge ref detection from `parse.body()` to `diff`

Problem: `parse.body()` stripped forge tokens from the description,
losing the raw text. This made inline overlay rendering impossible
since the token no longer existed in the buffer.

Solution: remove the `forge.parse_ref()` branch from `parse.body()`
and call `forge.find_refs()` in `diff.parse_buffer()` instead. The
description now retains forge tokens verbatim; `_extra._forge_ref`
is still populated from the first matched ref.

* feat(buffer): render forge links as inline conceal overlays

Problem: forge tokens were stripped from the buffer and shown as EOL
virtual text via `%l`. The token disappeared from the editable line,
and multi-ref tasks broke.

Solution: compute `forge_spans` in `views.lua` with byte offsets for
each forge token in the rendered line. In `apply_inline_row()`, place
extmarks with `conceal=''` and `virt_text_pos='inline'` to visually
replace each raw token with its formatted label. Clear stale
`forge_spans` on dirty rows to prevent `end_col` out-of-range errors
after edits like `dd`.

* fix(config): remove `%l` from default `eol_format`

Problem: forge links are now rendered inline, making the `%l` EOL
specifier redundant in the default format.

Solution: change default `eol_format` from `'%l  %c  %r  %d'` to
`'%c  %r  %d'`. The `%l` specifier remains functional for users who
explicitly set it.

* test(forge): update specs for inline forge refs

Problem: existing tests asserted that `parse.body()` stripped forge
tokens from the description and populated `meta.forge_ref`. The
`conceal_patterns` test referenced a removed function.

Solution: update `parse.body` integration tests to assert tokens stay
in the description. Add `find_refs()` tests covering single/multiple
refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns`
test. Update diff tests to assert description includes the token.

* docs: update forge links for inline overlay rendering

Problem: documentation described forge tokens as stripped from the
description and rendered via EOL `%l` specifier by default.

Solution: update forge links section to describe inline conceal
overlay rendering. Update default `eol_format` reference. Change
`issue_format` field description from "EOL label" to "inline overlay
label".

* ci: format

* refactor(forge): remove `%l` eol specifier, add `auto_close` config, fix icons

Problem: `%l` was dead code after inline overlays replaced EOL
rendering. Auto-close was always on with no opt-out. Forge icon
defaults were empty strings.

Solution: remove `%l` from the eol format parser and renderer. Add
`forge.auto_close` (default `false`) to gate state-pull. Set nerd
font icons: `` (GitHub), `` (GitLab), `` (Codeberg). Keep
conceal active in insert mode via `concealcursor = 'nic'`.

* fix(config): set correct nerd font icons for forge defaults
This commit is contained in:
Barrett Ruth 2026-03-10 20:01:10 -04:00 committed by GitHub
parent 07024671eb
commit 2998585587
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 22 additions and 23 deletions

View file

@ -842,7 +842,6 @@ Fields: ~
{eol_format} (string, default: '%c %r %d') {eol_format} (string, default: '%c %r %d')
Format string for end-of-line virtual text. Format string for end-of-line virtual text.
Specifiers: Specifiers:
`%l` forge link label (`PendingForge`)
`%c` category icon + name (`PendingHeader`) `%c` category icon + name (`PendingHeader`)
`%r` recurrence icon + pattern (`PendingRecur`) `%r` recurrence icon + pattern (`PendingRecur`)
`%d` due icon + date (`PendingDue`/`PendingOverdue`) `%d` due icon + date (`PendingDue`/`PendingOverdue`)
@ -1486,10 +1485,6 @@ inline with a formatted label using overlay extmarks (same technique as
checkbox icons). Multiple forge references in one line are each overlaid checkbox icons). Multiple forge references in one line are each overlaid
independently. independently.
The `%l` specifier in `eol_format` is still supported for users who prefer
the link label in EOL virtual text, but it is no longer in the default
format (`'%c %r %d'`).
Format string: ~ Format string: ~
*pending-forge-format* *pending-forge-format*
Each forge has a configurable `issue_format` string with these placeholders: Each forge has a configurable `issue_format` string with these placeholders:
@ -1505,6 +1500,7 @@ Configuration: ~
>lua >lua
vim.g.pending = { vim.g.pending = {
forge = { forge = {
auto_close = false,
github = { github = {
token = nil, token = nil,
icon = '', icon = '',
@ -1527,6 +1523,11 @@ Configuration: ~
} }
< <
Top-level fields: ~
{auto_close} (boolean, default: false) When true, tasks linked to
closed/merged remote issues are automatically marked
done on buffer open.
Fields (per forge): ~ Fields (per forge): ~
{token} (string, optional) API token for authenticated requests. {token} (string, optional) API token for authenticated requests.
Falls back to CLI: `gh auth token` (GitHub), `glab auth Falls back to CLI: `gh auth token` (GitHub), `glab auth
@ -1551,9 +1552,10 @@ than 5 minutes are re-fetched asynchronously. The buffer renders immediately
with cached data and updates extmarks when the fetch completes. with cached data and updates extmarks when the fetch completes.
State pull: ~ State pull: ~
After fetching, if the remote issue/PR is closed or merged and the local Requires `forge.auto_close = true`. After fetching, if the remote issue/PR
task is pending/wip/blocked, the task is automatically marked as done. This is closed or merged and the local task is pending/wip/blocked, the task is
is one-way: local status changes do not push back to the forge. automatically marked as done. Disabled by default. One-way: local status
changes do not push back to the forge.
Highlight groups: ~ Highlight groups: ~
|PendingForge| Open issue/PR link label |PendingForge| Open issue/PR link label

View file

@ -379,7 +379,7 @@ end
---@param winid integer ---@param winid integer
local function set_win_options(winid) local function set_win_options(winid)
vim.wo[winid].conceallevel = 3 vim.wo[winid].conceallevel = 3
vim.wo[winid].concealcursor = 'nc' vim.wo[winid].concealcursor = 'nic'
vim.wo[winid].winfixheight = true vim.wo[winid].winfixheight = true
end end
@ -446,7 +446,7 @@ end
---@class pending.EolSegment ---@class pending.EolSegment
---@field type 'specifier'|'literal' ---@field type 'specifier'|'literal'
---@field key? 'c'|'r'|'d'|'l' ---@field key? 'c'|'r'|'d'
---@field text? string ---@field text? string
---@param fmt string ---@param fmt string
@ -458,7 +458,7 @@ local function parse_eol_format(fmt)
while pos <= len do while pos <= len do
if fmt:sub(pos, pos) == '%' and pos + 1 <= len then if fmt:sub(pos, pos) == '%' and pos + 1 <= len then
local key = fmt:sub(pos + 1, pos + 1) local key = fmt:sub(pos + 1, pos + 1)
if key == 'c' or key == 'r' or key == 'd' or key == 'l' then if key == 'c' or key == 'r' or key == 'd' then
table.insert(segments, { type = 'specifier', key = key }) table.insert(segments, { type = 'specifier', key = key })
pos = pos + 2 pos = pos + 2
else else
@ -485,10 +485,7 @@ local function build_eol_virt(segments, m, icons)
for i, seg in ipairs(segments) do for i, seg in ipairs(segments) do
if seg.type == 'specifier' then if seg.type == 'specifier' then
local text, hl local text, hl
if seg.key == 'l' and m.forge_ref then if seg.key == 'c' and m.show_category and m.category then
local forge = require('pending.forge')
text, hl = forge.format_label(m.forge_ref, m.forge_cache)
elseif seg.key == 'c' and m.show_category and m.category then
text = icons.category .. ' ' .. m.category text = icons.category .. ' ' .. m.category
hl = 'PendingHeader' hl = 'PendingHeader'
elseif seg.key == 'r' and m.recur then elseif seg.key == 'r' and m.recur then

View file

@ -40,6 +40,7 @@
---@field instances? string[] ---@field instances? string[]
---@class pending.ForgeConfig ---@class pending.ForgeConfig
---@field auto_close? boolean
---@field github? pending.ForgeInstanceConfig ---@field github? pending.ForgeInstanceConfig
---@field gitlab? pending.ForgeInstanceConfig ---@field gitlab? pending.ForgeInstanceConfig
---@field codeberg? pending.ForgeInstanceConfig ---@field codeberg? pending.ForgeInstanceConfig
@ -154,18 +155,19 @@ local defaults = {
}, },
sync = {}, sync = {},
forge = { forge = {
auto_close = false,
github = { github = {
icon = '', icon = '',
issue_format = '%i %o/%r#%n', issue_format = '%i %o/%r#%n',
instances = {}, instances = {},
}, },
gitlab = { gitlab = {
icon = '', icon = '',
issue_format = '%i %o/%r#%n', issue_format = '%i %o/%r#%n',
instances = {}, instances = {},
}, },
codeberg = { codeberg = {
icon = '', icon = '',
issue_format = '%i %o/%r#%n', issue_format = '%i %o/%r#%n',
instances = {}, instances = {},
}, },

View file

@ -396,8 +396,10 @@ function M.refresh(s)
if cache then if cache then
task._extra._forge_cache = cache task._extra._forge_cache = cache
any_fetched = true any_fetched = true
local forge_cfg = config.get().forge or {}
if if
(cache.state == 'closed' or cache.state == 'merged') forge_cfg.auto_close
and (cache.state == 'closed' or cache.state == 'merged')
and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked')
then then
task.status = 'done' task.status = 'done'

View file

@ -19,8 +19,6 @@ local parse = require('pending.parse')
---@field show_category? boolean ---@field show_category? boolean
---@field priority? integer ---@field priority? integer
---@field recur? string ---@field recur? string
---@field forge_ref? pending.ForgeRef
---@field forge_cache? pending.ForgeCache
---@field forge_spans? pending.ForgeLineMeta[] ---@field forge_spans? pending.ForgeLineMeta[]
---@class pending.views ---@class pending.views
@ -219,8 +217,6 @@ function M.category_view(tasks)
priority = task.priority, priority = task.priority,
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil,
recur = task.recur, recur = task.recur,
forge_ref = task._extra and task._extra._forge_ref or nil,
forge_cache = task._extra and task._extra._forge_cache or nil,
forge_spans = compute_forge_spans(task, prefix_len), forge_spans = compute_forge_spans(task, prefix_len),
}) })
end end