* 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
283 lines
7 KiB
Lua
283 lines
7 KiB
Lua
local config = require('pending.config')
|
|
local forge = require('pending.forge')
|
|
local parse = require('pending.parse')
|
|
|
|
---@class pending.ForgeLineMeta
|
|
---@field ref pending.ForgeRef
|
|
---@field cache? pending.ForgeCache
|
|
---@field col_start integer
|
|
---@field col_end integer
|
|
|
|
---@class pending.LineMeta
|
|
---@field type 'task'|'header'|'blank'|'filter'
|
|
---@field id? integer
|
|
---@field due? string
|
|
---@field raw_due? string
|
|
---@field status? string
|
|
---@field category? string
|
|
---@field overdue? boolean
|
|
---@field show_category? boolean
|
|
---@field priority? integer
|
|
---@field recur? string
|
|
---@field forge_spans? pending.ForgeLineMeta[]
|
|
|
|
---@class pending.views
|
|
local M = {}
|
|
|
|
---@param due? string
|
|
---@return string?
|
|
local function format_due(due)
|
|
if not due then
|
|
return nil
|
|
end
|
|
local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
|
|
if not y then
|
|
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
|
end
|
|
if not y then
|
|
return due
|
|
end
|
|
local t = os.time({
|
|
year = tonumber(y) --[[@as integer]],
|
|
month = tonumber(m) --[[@as integer]],
|
|
day = tonumber(d) --[[@as integer]],
|
|
})
|
|
local formatted = os.date(config.get().date_format, t) --[[@as string]]
|
|
if hh then
|
|
formatted = formatted .. ' ' .. hh .. ':' .. mm
|
|
end
|
|
return formatted
|
|
end
|
|
|
|
---@param task pending.Task
|
|
---@param prefix_len integer
|
|
---@return pending.ForgeLineMeta[]?
|
|
local function compute_forge_spans(task, prefix_len)
|
|
local refs = forge.find_refs(task.description)
|
|
if #refs == 0 then
|
|
return nil
|
|
end
|
|
local cache = task._extra and task._extra._forge_cache or nil
|
|
local spans = {}
|
|
for _, r in ipairs(refs) do
|
|
table.insert(spans, {
|
|
ref = r.ref,
|
|
cache = cache,
|
|
col_start = prefix_len + r.start_byte,
|
|
col_end = prefix_len + r.end_byte,
|
|
})
|
|
end
|
|
return spans
|
|
end
|
|
|
|
---@type table<string, integer>
|
|
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 }
|
|
|
|
---@param task pending.Task
|
|
---@return string
|
|
local function state_char(task)
|
|
if task.status == 'done' then
|
|
return 'x'
|
|
elseif task.status == 'wip' then
|
|
return '>'
|
|
elseif task.status == 'blocked' then
|
|
return '='
|
|
elseif task.priority > 0 then
|
|
return '!'
|
|
end
|
|
return ' '
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
local function sort_tasks(tasks)
|
|
table.sort(tasks, function(a, b)
|
|
local ra = status_rank[a.status] or 1
|
|
local rb = status_rank[b.status] or 1
|
|
if ra ~= rb then
|
|
return ra < rb
|
|
end
|
|
if a.priority ~= b.priority then
|
|
return a.priority > b.priority
|
|
end
|
|
if a.order ~= b.order then
|
|
return a.order < b.order
|
|
end
|
|
return a.id < b.id
|
|
end)
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
local function sort_tasks_priority(tasks)
|
|
table.sort(tasks, function(a, b)
|
|
local ra = status_rank[a.status] or 1
|
|
local rb = status_rank[b.status] or 1
|
|
if ra ~= rb then
|
|
return ra < rb
|
|
end
|
|
if a.priority ~= b.priority then
|
|
return a.priority > b.priority
|
|
end
|
|
local a_due = a.due or ''
|
|
local b_due = b.due or ''
|
|
if a_due ~= b_due then
|
|
if a_due == '' then
|
|
return false
|
|
end
|
|
if b_due == '' then
|
|
return true
|
|
end
|
|
return a_due < b_due
|
|
end
|
|
if a.order ~= b.order then
|
|
return a.order < b.order
|
|
end
|
|
return a.id < b.id
|
|
end)
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
---@return string[] lines
|
|
---@return pending.LineMeta[] meta
|
|
function M.category_view(tasks)
|
|
local by_cat = {}
|
|
local cat_order = {}
|
|
local cat_seen = {}
|
|
local done_by_cat = {}
|
|
|
|
for _, task in ipairs(tasks) do
|
|
local cat = task.category or config.get().default_category
|
|
if not cat_seen[cat] then
|
|
cat_seen[cat] = true
|
|
table.insert(cat_order, cat)
|
|
by_cat[cat] = {}
|
|
done_by_cat[cat] = {}
|
|
end
|
|
if task.status == 'done' or task.status == 'deleted' then
|
|
table.insert(done_by_cat[cat], task)
|
|
else
|
|
table.insert(by_cat[cat], task)
|
|
end
|
|
end
|
|
|
|
local cfg_order = config.get().view.category.order
|
|
if cfg_order and #cfg_order > 0 then
|
|
local ordered = {}
|
|
local seen = {}
|
|
for _, name in ipairs(cfg_order) do
|
|
if cat_seen[name] then
|
|
table.insert(ordered, name)
|
|
seen[name] = true
|
|
end
|
|
end
|
|
for _, name in ipairs(cat_order) do
|
|
if not seen[name] then
|
|
table.insert(ordered, name)
|
|
end
|
|
end
|
|
cat_order = ordered
|
|
end
|
|
|
|
for _, cat in ipairs(cat_order) do
|
|
sort_tasks(by_cat[cat])
|
|
sort_tasks(done_by_cat[cat])
|
|
end
|
|
|
|
local lines = {}
|
|
local meta = {}
|
|
|
|
for i, cat in ipairs(cat_order) do
|
|
if i > 1 then
|
|
table.insert(lines, '')
|
|
table.insert(meta, { type = 'blank' })
|
|
end
|
|
table.insert(lines, '# ' .. cat)
|
|
table.insert(meta, { type = 'header', category = cat })
|
|
|
|
local all = {}
|
|
for _, t in ipairs(by_cat[cat]) do
|
|
table.insert(all, t)
|
|
end
|
|
for _, t in ipairs(done_by_cat[cat]) do
|
|
table.insert(all, t)
|
|
end
|
|
|
|
for _, task in ipairs(all) do
|
|
local prefix = '/' .. task.id .. '/'
|
|
local state = state_char(task)
|
|
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
|
local prefix_len = #prefix + #('- [' .. state .. '] ')
|
|
table.insert(lines, line)
|
|
table.insert(meta, {
|
|
type = 'task',
|
|
id = task.id,
|
|
due = format_due(task.due),
|
|
raw_due = task.due,
|
|
status = task.status,
|
|
category = cat,
|
|
priority = task.priority,
|
|
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil,
|
|
recur = task.recur,
|
|
forge_spans = compute_forge_spans(task, prefix_len),
|
|
})
|
|
end
|
|
end
|
|
|
|
return lines, meta
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
---@return string[] lines
|
|
---@return pending.LineMeta[] meta
|
|
function M.priority_view(tasks)
|
|
local pending = {}
|
|
local done = {}
|
|
|
|
for _, task in ipairs(tasks) do
|
|
if task.status == 'done' then
|
|
table.insert(done, task)
|
|
else
|
|
table.insert(pending, task)
|
|
end
|
|
end
|
|
|
|
sort_tasks_priority(pending)
|
|
sort_tasks_priority(done)
|
|
|
|
local lines = {}
|
|
local meta = {}
|
|
|
|
local all = {}
|
|
for _, t in ipairs(pending) do
|
|
table.insert(all, t)
|
|
end
|
|
for _, t in ipairs(done) do
|
|
table.insert(all, t)
|
|
end
|
|
|
|
for _, task in ipairs(all) do
|
|
local prefix = '/' .. task.id .. '/'
|
|
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
|
|
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
|
local prefix_len = #prefix + #('- [' .. state .. '] ')
|
|
table.insert(lines, line)
|
|
table.insert(meta, {
|
|
type = 'task',
|
|
id = task.id,
|
|
due = format_due(task.due),
|
|
raw_due = task.due,
|
|
status = task.status,
|
|
category = task.category,
|
|
priority = task.priority,
|
|
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil,
|
|
show_category = true,
|
|
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),
|
|
})
|
|
end
|
|
|
|
return lines, meta
|
|
end
|
|
|
|
return M
|