From 1bd2ef914bac42a4e043fdc92d66fdc0de57ca6b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:30:20 -0400 Subject: [PATCH 01/26] feat(diff): disallow editing done tasks by default (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format * feat(diff): disallow editing done tasks by default Problem: Done tasks could be freely edited in the buffer, leading to accidental modifications of completed work. Solution: Add a `lock_done` config option (default `true`) and a guard in `diff.apply()` that rejects field changes to done tasks unless the user toggles the checkbox back to pending first. --- lua/pending/config.lua | 2 + lua/pending/diff.lua | 161 ++++++++++++++++++++++------------------- spec/diff_spec.lua | 61 ++++++++++++++++ 3 files changed, 148 insertions(+), 76 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index b1ab639..6942c1b 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -100,6 +100,7 @@ ---@field view pending.ViewConfig ---@field max_priority? integer ---@field sync? pending.SyncConfig +---@field lock_done? boolean ---@field forge? pending.ForgeConfig ---@field icons pending.Icons @@ -114,6 +115,7 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + lock_done = true, max_priority = 3, view = { default = 'category', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 29c292b..a213584 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,5 +1,6 @@ local config = require('pending.config') local forge = require('pending.forge') +local log = require('pending.log') local parse = require('pending.parse') ---@class pending.ParsedEntry @@ -102,14 +103,91 @@ function M.apply(lines, s, hidden_ids) local order_counter = 0 for _, entry in ipairs(parsed) do - if entry.type ~= 'task' then - goto continue - end + if entry.type == 'task' then + order_counter = order_counter + 1 - order_counter = order_counter + 1 - - if entry.id and old_by_id[entry.id] then - if seen_ids[entry.id] then + if entry.id and old_by_id[entry.id] then + if seen_ids[entry.id] then + s:add({ + description = entry.description, + category = entry.category, + priority = entry.priority, + due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, + order = order_counter, + _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, + }) + else + seen_ids[entry.id] = true + local task = old_by_id[entry.id] + if + config.get().lock_done + and task.status == 'done' + and entry.status == 'done' + then + if task.order ~= order_counter then + task.order = order_counter + task.modified = now + end + log.warn('cannot edit done task — toggle status first') + else + local changed = false + if task.description ~= entry.description then + task.description = entry.description + changed = true + end + if task.category ~= entry.category then + task.category = entry.category + changed = true + end + if entry.priority == 0 and task.priority > 0 then + task.priority = 0 + changed = true + elseif entry.priority > 0 and task.priority == 0 then + task.priority = entry.priority + changed = true + end + if entry.due ~= nil and task.due ~= entry.due then + task.due = entry.due + changed = true + end + if entry.rec ~= nil then + if task.recur ~= entry.rec then + task.recur = entry.rec + changed = true + end + if task.recur_mode ~= entry.rec_mode then + task.recur_mode = entry.rec_mode + changed = true + end + end + if entry.forge_ref ~= nil then + if not task._extra then + task._extra = {} + end + task._extra._forge_ref = entry.forge_ref + changed = true + end + if entry.status and task.status ~= entry.status then + task.status = entry.status + if entry.status == 'done' then + task['end'] = now + else + task['end'] = nil + end + changed = true + end + if task.order ~= order_counter then + task.order = order_counter + changed = true + end + if changed then + task.modified = now + end + end + end + else s:add({ description = entry.description, category = entry.category, @@ -120,77 +198,8 @@ function M.apply(lines, s, hidden_ids) order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) - else - seen_ids[entry.id] = true - local task = old_by_id[entry.id] - local changed = false - if task.description ~= entry.description then - task.description = entry.description - changed = true - end - if task.category ~= entry.category then - task.category = entry.category - changed = true - end - if entry.priority == 0 and task.priority > 0 then - task.priority = 0 - changed = true - elseif entry.priority > 0 and task.priority == 0 then - task.priority = entry.priority - changed = true - end - if entry.due ~= nil and task.due ~= entry.due then - task.due = entry.due - changed = true - end - if entry.rec ~= nil then - if task.recur ~= entry.rec then - task.recur = entry.rec - changed = true - end - if task.recur_mode ~= entry.rec_mode then - task.recur_mode = entry.rec_mode - changed = true - end - end - if entry.forge_ref ~= nil then - if not task._extra then - task._extra = {} - end - task._extra._forge_ref = entry.forge_ref - changed = true - end - if entry.status and task.status ~= entry.status then - task.status = entry.status - if entry.status == 'done' then - task['end'] = now - else - task['end'] = nil - end - changed = true - end - if task.order ~= order_counter then - task.order = order_counter - changed = true - end - if changed then - task.modified = now - end end - else - s:add({ - description = entry.description, - category = entry.category, - priority = entry.priority, - due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, - order = order_counter, - _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, - }) end - - ::continue:: end for id, task in pairs(old_by_id) do diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 01d8aac..c897df1 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -287,5 +287,66 @@ describe('diff', function() local task = s:get(1) assert.are.equal(0, task.priority) end) + + it('rejects editing description of a done task', function() + local t = s:add({ description = 'Finished work', status = 'done' }) + t['end'] = '2026-03-01T00:00:00Z' + s:save() + local lines = { + '# Todo', + '/1/- [x] Changed description', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('Finished work', task.description) + assert.are.equal('done', task.status) + end) + + it('allows toggling done task back to pending', function() + local t = s:add({ description = 'Finished work', status = 'done' }) + t['end'] = '2026-03-01T00:00:00Z' + s:save() + local lines = { + '# Todo', + '/1/- [ ] Finished work', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('pending', task.status) + end) + + it('allows editing done task when lock_done is false', function() + local cfg = require('pending.config') + vim.g.pending = { lock_done = false } + cfg.reset() + local t = s:add({ description = 'Finished work', status = 'done' }) + t['end'] = '2026-03-01T00:00:00Z' + s:save() + local lines = { + '# Todo', + '/1/- [x] Changed description', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('Changed description', task.description) + vim.g.pending = {} + cfg.reset() + end) + + it('does not affect editing of pending tasks', function() + s:add({ description = 'Active task' }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Updated active task', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('Updated active task', task.description) + end) end) end) From 26b8bb4beb9b9c6359ac2d5252c8bc831f5190da Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:30:42 -0400 Subject: [PATCH 02/26] feat(forge): support custom shorthand prefixes (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format * feat(forge): support custom shorthand prefixes Problem: forge shorthand parsing hardcoded `%l%l` (exactly 2 lowercase letters), preventing custom prefixes like `github:`. Completions also hardcoded `gh:`, `gl:`, `cb:` patterns. Solution: iterate `_by_shorthand` keys dynamically in `_parse_shorthand` instead of matching a fixed pattern. Build completion patterns from `forge.backends()`. Add `shorthand` field to `ForgeInstanceConfig` so users can override prefixes via config, applied in `_ensure_instances()`. --- lua/pending/complete.lua | 20 +++++++++--- lua/pending/config.lua | 1 + lua/pending/forge.lua | 26 +++++++++++++--- spec/forge_spec.lua | 67 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 135d1a4..98291ce 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local forge = require('pending.forge') ---@class pending.CompletionItem ---@field word string @@ -109,6 +110,17 @@ local function recur_completions() return result end +---@param source string +---@return boolean +function M._is_forge_source(source) + for _, b in ipairs(forge.backends()) do + if b.shorthand == source then + return true + end + end + return false +end + ---@type string? local _complete_source = nil @@ -128,10 +140,10 @@ function M.omnifunc(findstart, base) { vim.pesc(dk) .. ':([%S]*)$', dk }, { 'cat:([%S]*)$', 'cat' }, { vim.pesc(rk) .. ':([%S]*)$', rk }, - { 'gh:([%S]*)$', 'gh' }, - { 'gl:([%S]*)$', 'gl' }, - { 'cb:([%S]*)$', 'cb' }, } + for _, b in ipairs(forge.backends()) do + table.insert(checks, { vim.pesc(b.shorthand) .. ':([%S]*)$', b.shorthand }) + end for _, check in ipairs(checks) do local start = before:find(check[1]) @@ -172,7 +184,7 @@ function M.omnifunc(findstart, base) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) end end - elseif source == 'gh' or source == 'gl' or source == 'cb' then + elseif M._is_forge_source(source) then local s = require('pending.buffer').store() if s then local seen = {} diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 6942c1b..1359f9a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -37,6 +37,7 @@ ---@field icon? string ---@field issue_format? string ---@field instances? string[] +---@field shorthand? string ---@class pending.ForgeConfig ---@field auto_close? boolean diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 9b32655..baebc0a 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -62,6 +62,14 @@ function M.backends() return _backends end +function M._reset_instances() + _instances_resolved = false + _by_shorthand = {} + for _, b in ipairs(_backends) do + _by_shorthand[b.shorthand] = b + end +end + local function _ensure_instances() if _instances_resolved then return @@ -73,17 +81,27 @@ local function _ensure_instances() for _, inst in ipairs(forge_cfg.instances or {}) do _by_host[inst] = backend end + if forge_cfg.shorthand and forge_cfg.shorthand ~= backend.shorthand then + _by_shorthand[backend.shorthand] = nil + backend.shorthand = forge_cfg.shorthand + _by_shorthand[backend.shorthand] = backend + end end end ---@param token string ---@return pending.ForgeRef? function M._parse_shorthand(token) - local prefix, rest = token:match('^(%l%l):(.+)$') - if not prefix then - return nil + _ensure_instances() + local backend, rest + for prefix, b in pairs(_by_shorthand) do + local candidate = token:match('^' .. vim.pesc(prefix) .. ':(.+)$') + if candidate then + backend = b + rest = candidate + break + end end - local backend = _by_shorthand[prefix] if not backend then return nil end diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index fac8021..8bd4162 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -391,6 +391,73 @@ describe('forge registry', function() end) end) +describe('custom forge prefixes', function() + local config = require('pending.config') + local complete = require('pending.complete') + + it('parses custom-length shorthand (3+ chars)', function() + local custom = forge.gitea_backend({ + name = 'customforge', + shorthand = 'cgf', + default_host = 'custom.example.com', + }) + forge.register(custom) + + local ref = forge._parse_shorthand('cgf:alice/proj#99') + assert.is_not_nil(ref) + assert.equals('customforge', ref.forge) + assert.equals('alice', ref.owner) + assert.equals('proj', ref.repo) + assert.equals(99, ref.number) + end) + + it('parse_ref dispatches custom-length shorthand', function() + local ref = forge.parse_ref('cgf:alice/proj#5') + assert.is_not_nil(ref) + assert.equals('customforge', ref.forge) + assert.equals(5, ref.number) + end) + + it('find_refs finds custom-length shorthand', function() + local refs = forge.find_refs('Fix cgf:alice/proj#12') + assert.equals(1, #refs) + assert.equals('customforge', refs[1].ref.forge) + assert.equals(12, refs[1].ref.number) + end) + + it('completion returns entries for custom backends', function() + assert.is_true(complete._is_forge_source('cgf')) + end) + + it('config shorthand override re-registers backend', function() + vim.g.pending = { + forge = { + github = { shorthand = 'github' }, + }, + } + config.reset() + forge._reset_instances() + + local ref = forge._parse_shorthand('github:user/repo#1') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals(1, ref.number) + + assert.is_nil(forge._parse_shorthand('gh:user/repo#1')) + + vim.g.pending = nil + config.reset() + for _, b in ipairs(forge.backends()) do + if b.name == 'github' then + b.shorthand = 'gh' + end + end + forge._reset_instances() + end) +end) + describe('forge diff integration', function() local store = require('pending.store') local diff = require('pending.diff') From 343dbb202b64227a9f4359fc406e56ffabb0d35d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:45:07 -0400 Subject: [PATCH 03/26] Revert "feat(diff): disallow editing done tasks by default (#132)" (#133) This reverts commit 24e8741ae16118bd48efb8270ca869e1efe77ee0. --- lua/pending/config.lua | 2 - lua/pending/diff.lua | 161 +++++++++++++++++++---------------------- spec/diff_spec.lua | 61 ---------------- 3 files changed, 76 insertions(+), 148 deletions(-) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 1359f9a..5a139cb 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -101,7 +101,6 @@ ---@field view pending.ViewConfig ---@field max_priority? integer ---@field sync? pending.SyncConfig ----@field lock_done? boolean ---@field forge? pending.ForgeConfig ---@field icons pending.Icons @@ -116,7 +115,6 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', - lock_done = true, max_priority = 3, view = { default = 'category', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index a213584..29c292b 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,6 +1,5 @@ local config = require('pending.config') local forge = require('pending.forge') -local log = require('pending.log') local parse = require('pending.parse') ---@class pending.ParsedEntry @@ -103,91 +102,14 @@ function M.apply(lines, s, hidden_ids) local order_counter = 0 for _, entry in ipairs(parsed) do - if entry.type == 'task' then - order_counter = order_counter + 1 + if entry.type ~= 'task' then + goto continue + end - if entry.id and old_by_id[entry.id] then - if seen_ids[entry.id] then - s:add({ - description = entry.description, - category = entry.category, - priority = entry.priority, - due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, - order = order_counter, - _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, - }) - else - seen_ids[entry.id] = true - local task = old_by_id[entry.id] - if - config.get().lock_done - and task.status == 'done' - and entry.status == 'done' - then - if task.order ~= order_counter then - task.order = order_counter - task.modified = now - end - log.warn('cannot edit done task — toggle status first') - else - local changed = false - if task.description ~= entry.description then - task.description = entry.description - changed = true - end - if task.category ~= entry.category then - task.category = entry.category - changed = true - end - if entry.priority == 0 and task.priority > 0 then - task.priority = 0 - changed = true - elseif entry.priority > 0 and task.priority == 0 then - task.priority = entry.priority - changed = true - end - if entry.due ~= nil and task.due ~= entry.due then - task.due = entry.due - changed = true - end - if entry.rec ~= nil then - if task.recur ~= entry.rec then - task.recur = entry.rec - changed = true - end - if task.recur_mode ~= entry.rec_mode then - task.recur_mode = entry.rec_mode - changed = true - end - end - if entry.forge_ref ~= nil then - if not task._extra then - task._extra = {} - end - task._extra._forge_ref = entry.forge_ref - changed = true - end - if entry.status and task.status ~= entry.status then - task.status = entry.status - if entry.status == 'done' then - task['end'] = now - else - task['end'] = nil - end - changed = true - end - if task.order ~= order_counter then - task.order = order_counter - changed = true - end - if changed then - task.modified = now - end - end - end - else + order_counter = order_counter + 1 + + if entry.id and old_by_id[entry.id] then + if seen_ids[entry.id] then s:add({ description = entry.description, category = entry.category, @@ -198,8 +120,77 @@ function M.apply(lines, s, hidden_ids) order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) + else + seen_ids[entry.id] = true + local task = old_by_id[entry.id] + local changed = false + if task.description ~= entry.description then + task.description = entry.description + changed = true + end + if task.category ~= entry.category then + task.category = entry.category + changed = true + end + if entry.priority == 0 and task.priority > 0 then + task.priority = 0 + changed = true + elseif entry.priority > 0 and task.priority == 0 then + task.priority = entry.priority + changed = true + end + if entry.due ~= nil and task.due ~= entry.due then + task.due = entry.due + changed = true + end + if entry.rec ~= nil then + if task.recur ~= entry.rec then + task.recur = entry.rec + changed = true + end + if task.recur_mode ~= entry.rec_mode then + task.recur_mode = entry.rec_mode + changed = true + end + end + if entry.forge_ref ~= nil then + if not task._extra then + task._extra = {} + end + task._extra._forge_ref = entry.forge_ref + changed = true + end + if entry.status and task.status ~= entry.status then + task.status = entry.status + if entry.status == 'done' then + task['end'] = now + else + task['end'] = nil + end + changed = true + end + if task.order ~= order_counter then + task.order = order_counter + changed = true + end + if changed then + task.modified = now + end end + else + s:add({ + description = entry.description, + category = entry.category, + priority = entry.priority, + due = entry.due, + recur = entry.rec, + recur_mode = entry.rec_mode, + order = order_counter, + _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, + }) end + + ::continue:: end for id, task in pairs(old_by_id) do diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index c897df1..01d8aac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -287,66 +287,5 @@ describe('diff', function() local task = s:get(1) assert.are.equal(0, task.priority) end) - - it('rejects editing description of a done task', function() - local t = s:add({ description = 'Finished work', status = 'done' }) - t['end'] = '2026-03-01T00:00:00Z' - s:save() - local lines = { - '# Todo', - '/1/- [x] Changed description', - } - diff.apply(lines, s) - s:load() - local task = s:get(1) - assert.are.equal('Finished work', task.description) - assert.are.equal('done', task.status) - end) - - it('allows toggling done task back to pending', function() - local t = s:add({ description = 'Finished work', status = 'done' }) - t['end'] = '2026-03-01T00:00:00Z' - s:save() - local lines = { - '# Todo', - '/1/- [ ] Finished work', - } - diff.apply(lines, s) - s:load() - local task = s:get(1) - assert.are.equal('pending', task.status) - end) - - it('allows editing done task when lock_done is false', function() - local cfg = require('pending.config') - vim.g.pending = { lock_done = false } - cfg.reset() - local t = s:add({ description = 'Finished work', status = 'done' }) - t['end'] = '2026-03-01T00:00:00Z' - s:save() - local lines = { - '# Todo', - '/1/- [x] Changed description', - } - diff.apply(lines, s) - s:load() - local task = s:get(1) - assert.are.equal('Changed description', task.description) - vim.g.pending = {} - cfg.reset() - end) - - it('does not affect editing of pending tasks', function() - s:add({ description = 'Active task' }) - s:save() - local lines = { - '# Todo', - '/1/- [ ] Updated active task', - } - diff.apply(lines, s) - s:load() - local task = s:get(1) - assert.are.equal('Updated active task', task.description) - end) end) end) From 0d62cd9e4088aa5bb7a5ca36bd306ecdbb601a8c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:18:20 -0400 Subject: [PATCH 04/26] refactor(forge): rename `auto_close` to `close` (#137) Problem: `auto_close` is verbose given it's already namespaced under `forge.` in the config table. Solution: Rename to `close` in config defaults, class annotation, `refresh()` usage, and vimdoc. --- doc/pending.txt | 6 +++--- lua/pending/config.lua | 4 ++-- lua/pending/forge.lua | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 9195c76..3078ea9 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1500,7 +1500,7 @@ Configuration: ~ >lua vim.g.pending = { forge = { - auto_close = false, + close = false, warn_missing_cli = true, github = { icon = '', @@ -1522,7 +1522,7 @@ Configuration: ~ < Top-level fields: ~ - {auto_close} (boolean, default: false) When true, tasks linked to + {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked done on buffer open. {warn_missing_cli} (boolean, default: true) When true, warns once per @@ -1550,7 +1550,7 @@ than 5 minutes are re-fetched asynchronously. The buffer renders immediately with cached data and updates extmarks when the fetch completes. State pull: ~ -Requires `forge.auto_close = true`. After fetching, if the remote issue/PR +Requires `forge.close = true`. After fetching, if the remote issue/PR is closed or merged and the local task is pending/wip/blocked, the task is automatically marked as done. Disabled by default. One-way: local status changes do not push back to the forge. diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 5a139cb..60775fe 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -40,7 +40,7 @@ ---@field shorthand? string ---@class pending.ForgeConfig ----@field auto_close? boolean +---@field close? boolean ---@field warn_missing_cli? boolean ---@field [string] pending.ForgeInstanceConfig @@ -154,7 +154,7 @@ local defaults = { }, sync = {}, forge = { - auto_close = false, + close = false, warn_missing_cli = true, github = { icon = '', diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index baebc0a..43150c0 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -310,7 +310,7 @@ function M.refresh(s) any_fetched = true local forge_cfg = config.get().forge or {} if - forge_cfg.auto_close + forge_cfg.close and (cache.state == 'closed' or cache.state == 'merged') and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') then From ff9f601f68bb726834cefe905419854943bdf159 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:28:52 -0400 Subject: [PATCH 05/26] fix(forge): fix ghost extmarks, false auth warnings, and needless API calls (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format * fix(forge): fix ghost extmarks, false auth warnings, and needless API calls Problem: extmarks ghosted after `cc`/undo on task lines, auth warnings fired even when CLIs were authenticated, and `refresh()` hit forge APIs on every buffer open regardless of `auto_close`. Solution: add `invalidate = true` to all extmarks so Neovim cleans them up on text deletion. Run `auth status` before warning to verify the CLI is actually unauthenticated. Gate `refresh()` behind `auto_close` config. * ci: typing and formatting --- doc/pending.txt | 2 +- lua/pending/buffer.lua | 8 ++++++++ lua/pending/forge.lua | 40 +++++++++++++++++++++++++++------------- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 3078ea9..a7250ea 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1522,7 +1522,7 @@ Configuration: ~ < Top-level fields: ~ - {close} (boolean, default: false) When true, tasks linked to + {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked done on buffer open. {warn_missing_cli} (boolean, default: true) When true, warns once per diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 8c0433e..b731262 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -128,6 +128,7 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { end_col = #line, hl_group = 'PendingFilter', + invalidate = true, }) elseif m.type == 'task' then if m.status == 'done' then @@ -136,6 +137,7 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { end_col = #line, hl_group = 'PendingDone', + invalidate = true, }) elseif m.status == 'blocked' then local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' @@ -143,6 +145,7 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { end_col = #line, hl_group = 'PendingBlocked', + invalidate = true, }) end local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' @@ -167,6 +170,7 @@ local function apply_inline_row(bufnr, row, m, icons) virt_text = { { '[' .. icon .. ']', icon_hl } }, virt_text_pos = 'overlay', priority = 100, + invalidate = true, }) if m.forge_spans then local forge = require('pending.forge') @@ -178,6 +182,7 @@ local function apply_inline_row(bufnr, row, m, icons) virt_text = { { label_text, hl_group } }, virt_text_pos = 'inline', priority = 90, + invalidate = true, }) end end @@ -186,11 +191,13 @@ local function apply_inline_row(bufnr, row, m, icons) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { end_col = #line, hl_group = 'PendingHeader', + invalidate = true, }) vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { virt_text = { { icons.category .. ' ', 'PendingHeader' } }, virt_text_pos = 'overlay', priority = 100, + invalidate = true, }) end end @@ -541,6 +548,7 @@ local function apply_extmarks(bufnr, line_meta) vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { virt_text = virt_parts, virt_text_pos = 'eol', + invalidate = true, }) end end diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 43150c0..145dd15 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -21,6 +21,7 @@ local log = require('pending.log') ---@field default_host string ---@field cli string ---@field auth_cmd string +---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ---@field _warned boolean @@ -249,17 +250,23 @@ function M.fetch_metadata(ref, callback) vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then - vim.schedule(function() - local forge_cfg = config.get().forge or {} - local backend = _by_name[ref.forge] - if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then - backend._warned = true - log.warn( - ('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) - ) - end - callback(nil) - end) + local forge_cfg = config.get().forge or {} + local backend = _by_name[ref.forge] + if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then + backend._warned = true + vim.system(backend.auth_status_args, { text = true }, function(auth_result) + vim.schedule(function() + if auth_result.code ~= 0 then + log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + callback(nil) + end) + end) + else + vim.schedule(function() + callback(nil) + end) + end return end local ok, decoded = pcall(vim.json.decode, result.stdout) @@ -295,6 +302,10 @@ end ---@param s pending.Store function M.refresh(s) + local forge_cfg = config.get().forge or {} + if not forge_cfg.close then + return + end local tasks = s:tasks() local pending_fetches = 0 local any_changed = false @@ -308,7 +319,6 @@ function M.refresh(s) if cache then task._extra._forge_cache = cache any_fetched = true - local forge_cfg = config.get().forge or {} if forge_cfg.close and (cache.state == 'closed' or cache.state == 'merged') @@ -346,7 +356,7 @@ function M.refresh(s) end end ----@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, default_icon?: string, default_issue_format?: string} +---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string} ---@return pending.ForgeBackend function M.gitea_backend(opts) return { @@ -355,6 +365,7 @@ function M.gitea_backend(opts) default_host = opts.default_host, cli = opts.cli or 'tea', auth_cmd = opts.auth_cmd or 'tea login add', + auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' }, default_icon = opts.default_icon or '', default_issue_format = opts.default_issue_format or '%i %o/%r#%n', _warned = false, @@ -405,6 +416,7 @@ M.register({ default_host = 'github.com', cli = 'gh', auth_cmd = 'gh auth login', + auth_status_args = { 'gh', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', _warned = false, @@ -455,6 +467,7 @@ M.register({ default_host = 'gitlab.com', cli = 'glab', auth_cmd = 'glab auth login', + auth_status_args = { 'glab', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', _warned = false, @@ -510,6 +523,7 @@ M.register({ default_host = 'codeberg.org', cli = 'tea', auth_cmd = 'tea login add', + auth_status_args = { 'tea', 'login', 'list' }, default_icon = '', default_issue_format = '%i %o/%r#%n', _warned = false, From 6f71ab14adb18782694a29b15e0e0999eedac02f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:23:20 -0400 Subject: [PATCH 06/26] feat(forge): add `validate` option for forge ref validation on write (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Typos in forge refs like `gh:user/repo#42` silently persist — there's no feedback when a ref points to a nonexistent issue. Solution: Add `forge.validate` config option. When enabled, `diff.apply()` returns new/changed `ForgeRef[]` and `forge.validate_refs()` fetches metadata for each, logging specific warnings for not-found, auth, or CLI-missing errors. --- doc/pending.txt | 5 +++ lua/pending/config.lua | 2 ++ lua/pending/diff.lua | 25 ++++++++++++- lua/pending/forge.lua | 82 +++++++++++++++++++++++++++++++++--------- lua/pending/init.lua | 6 +++- spec/diff_spec.lua | 62 ++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 19 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index a7250ea..e80349e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1501,6 +1501,7 @@ Configuration: ~ vim.g.pending = { forge = { close = false, + validate = false, warn_missing_cli = true, github = { icon = '', @@ -1525,6 +1526,10 @@ Top-level fields: ~ {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked done on buffer open. + {validate} (boolean, default: false) When true, new or changed + forge refs are validated on `:w` by fetching metadata. + Logs a warning if the ref is not found, auth fails, or + the CLI is missing. {warn_missing_cli} (boolean, default: true) When true, warns once per forge per session if the CLI is missing or fails. diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 60775fe..2ec13cc 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -41,6 +41,7 @@ ---@class pending.ForgeConfig ---@field close? boolean +---@field validate? boolean ---@field warn_missing_cli? boolean ---@field [string] pending.ForgeInstanceConfig @@ -155,6 +156,7 @@ local defaults = { sync = {}, forge = { close = false, + validate = false, warn_missing_cli = true, github = { icon = '', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 29c292b..9ec3043 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -82,14 +82,26 @@ function M.parse_buffer(lines) return result end +---@param a? pending.ForgeRef +---@param b? pending.ForgeRef +---@return boolean +local function refs_equal(a, b) + if not a or not b then + return false + end + return a.forge == b.forge and a.owner == b.owner + and a.repo == b.repo and a.number == b.number +end + ---@param lines string[] ---@param s pending.Store ---@param hidden_ids? table ----@return nil +---@return pending.ForgeRef[] function M.apply(lines, s, hidden_ids) local parsed = M.parse_buffer(lines) local now = timestamp() local data = s:data() + local new_refs = {} ---@type pending.ForgeRef[] local old_by_id = {} for _, task in ipairs(data.tasks) do @@ -120,6 +132,9 @@ function M.apply(lines, s, hidden_ids) order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) + if entry.forge_ref then + table.insert(new_refs, entry.forge_ref) + end else seen_ids[entry.id] = true local task = old_by_id[entry.id] @@ -154,6 +169,10 @@ function M.apply(lines, s, hidden_ids) end end if entry.forge_ref ~= nil then + local old_ref = task._extra and task._extra._forge_ref or nil + if not refs_equal(old_ref, entry.forge_ref) then + table.insert(new_refs, entry.forge_ref) + end if not task._extra then task._extra = {} end @@ -188,6 +207,9 @@ function M.apply(lines, s, hidden_ids) order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) + if entry.forge_ref then + table.insert(new_refs, entry.forge_ref) + end end ::continue:: @@ -202,6 +224,7 @@ function M.apply(lines, s, hidden_ids) end s:save() + return new_refs end return M diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 145dd15..7f76d00 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -243,30 +243,51 @@ function M.format_label(ref, cache) return text, hl end +---@class pending.ForgeFetchError +---@field code 'not_found'|'auth'|'cli_missing'|'network' +---@field message string + ---@param ref pending.ForgeRef ----@param callback fun(cache: pending.ForgeCache?) +---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?) function M.fetch_metadata(ref, callback) local args = M._api_args(ref) + local backend = _by_name[ref.forge] + + if backend and vim.fn.executable(backend.cli) == 0 then + vim.schedule(function() + local forge_cfg = config.get().forge or {} + if forge_cfg.warn_missing_cli ~= false and not backend._warned then + backend._warned = true + log.warn(('%s not installed'):format(backend.cli)) + end + callback(nil, { code = 'cli_missing', message = backend.cli .. ' not installed' }) + end) + return + end vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then - local forge_cfg = config.get().forge or {} - local backend = _by_name[ref.forge] - if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then - backend._warned = true - vim.system(backend.auth_status_args, { text = true }, function(auth_result) - vim.schedule(function() - if auth_result.code ~= 0 then - log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)) - end - callback(nil) - end) - end) - else - vim.schedule(function() - callback(nil) - end) + local stderr = result.stderr or '' + local err_code = 'network' ---@type 'not_found'|'auth'|'network' + if stderr:find('404') or stderr:find('Not Found') then + err_code = 'not_found' + elseif stderr:find('401') or stderr:find('requires authentication') then + err_code = 'auth' end + vim.schedule(function() + local forge_cfg = config.get().forge or {} + if + backend + and forge_cfg.warn_missing_cli ~= false + and not backend._warned + then + backend._warned = true + log.warn( + ('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) + ) + end + callback(nil, { code = err_code, message = stderr }) + end) return end local ok, decoded = pcall(vim.json.decode, result.stdout) @@ -567,4 +588,31 @@ M.register({ end, }) +---@param refs pending.ForgeRef[] +function M.validate_refs(refs) + local forge_cfg = config.get().forge or {} + if not forge_cfg.validate then + return + end + for _, ref in ipairs(refs) do + M.fetch_metadata(ref, function(_, err) + if err then + local label = ref.owner .. '/' .. ref.repo .. '#' .. ref.number + local backend = _by_name[ref.forge] + if err.code == 'not_found' then + log.warn(('%s not found — check owner, repo, and number'):format(label)) + elseif err.code == 'auth' then + local cmd = backend and backend.auth_cmd or 'auth' + log.warn(('%s: not authenticated — run `%s`'):format(label, cmd)) + elseif err.code == 'cli_missing' then + local cli = backend and backend.cli or 'cli' + log.warn(('%s: %s not installed'):format(label, cli)) + else + log.warn(('%s: validation failed'):format(label)) + end + end + end) + end +end + return M diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 6c5eaee..9b642ed 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -489,9 +489,13 @@ function M._on_write(bufnr) if #stack > UNDO_MAX then table.remove(stack, 1) end - diff.apply(lines, s, hidden) + local new_refs = diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) + if new_refs and #new_refs > 0 then + local forge = require('pending.forge') + forge.validate_refs(new_refs) + end end ---@return nil diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 01d8aac..e471e97 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -275,6 +275,68 @@ describe('diff', function() assert.are.equal('completion', tasks[1].recur_mode) end) + it('returns forge refs for new tasks', function() + local lines = { + '# Inbox', + '- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal('github', refs[1].forge) + assert.are.equal(42, refs[1].number) + end) + + it('returns forge refs for changed refs on existing tasks', function() + s:add({ description = 'Fix bug gh:user/repo#1', _extra = { + _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 1, url = '' }, + } }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#99', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal(99, refs[1].number) + end) + + it('returns empty when forge ref is unchanged', function() + s:add({ description = 'Fix bug gh:user/repo#42', _extra = { + _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '' }, + } }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(0, #refs) + end) + + it('returns empty for tasks without forge refs', function() + local lines = { + '# Inbox', + '- [ ] Plain task', + } + local refs = diff.apply(lines, s) + assert.are.equal(0, #refs) + end) + + it('returns forge refs for duplicated tasks', function() + s:add({ description = 'Fix bug gh:user/repo#42', _extra = { + _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '' }, + } }) + s:save() + local lines = { + '# Todo', + '/1/- [ ] Fix bug gh:user/repo#42', + '/1/- [ ] Fix bug gh:user/repo#42', + } + local refs = diff.apply(lines, s) + assert.are.equal(1, #refs) + assert.are.equal(42, refs[1].number) + end) + it('clears priority when [N] is removed from buffer line', function() s:add({ description = 'Task name', priority = 1 }) s:save() From 1064b7535a3726b42e45d73c8db238b24741e08e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:28:22 -0400 Subject: [PATCH 07/26] refactor(forge): simplify auth gating (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor(forge): replace curl/token auth with CLI-native API calls Problem: Forge metadata fetching required manual token management — config fields, CLI token extraction, and curl with auth headers. Each forge had a different auth path, and Codeberg had no CLI support at all. Solution: Delete `get_token()` and `_api_url()`, replace with `_api_args()` that builds `gh api`, `glab api`, or `tea api` arg arrays. The CLIs handle auth internally. Add `warn_missing_cli` config (default true) that warns once per forge per session on failure. Add forge CLI checks to `:checkhealth`. Remove `token` from config/docs. * refactor(forge): extract ForgeBackend class and registry Problem: adding a new forge required touching 5 lookup tables (`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`, `_warned_forges`) and every branching site in `_api_args`, `fetch_metadata`, and `parse_ref`. Solution: introduce a `ForgeBackend` class with `parse_url`, `api_args`, and `parse_state` methods, plus a `register()` / `backends()` registry. New forges (Gitea, Forgejo) are a single `register()` call via the `gitea_backend()` convenience constructor. * ci: format * fix(forge): fix ghost extmarks, false auth warnings, and needless API calls Problem: extmarks ghosted after `cc`/undo on task lines, auth warnings fired even when CLIs were authenticated, and `refresh()` hit forge APIs on every buffer open regardless of `auto_close`. Solution: add `invalidate = true` to all extmarks so Neovim cleans them up on text deletion. Run `auth status` before warning to verify the CLI is actually unauthenticated. Gate `refresh()` behind `auto_close` config. * ci: typing and formatting * refactor(forge): simplify auth gating and rename `gitea_backend` Problem: forge auth/warning logic was scattered through `fetch_metadata` — per-API-call auth status checks, `_warned` flags, and `warn_missing_cli` conditionals on every fetch. Solution: replace `_warned` with `_auth` (cached per session), add `is_configured()` to skip unconfigured forges entirely, extract `check_auth()` for one-time auth verification, and strip `fetch_metadata` to a pure API caller returning `ForgeFetchError`. Gate `refresh` and new `validate_refs` with both checks. Rename `gitea_backend` to `gitea_forge`. --- doc/pending.txt | 4 +- lua/pending/diff.lua | 3 +- lua/pending/forge.lua | 251 +++++++++++++++++++++++------------------ lua/pending/health.lua | 4 +- spec/diff_spec.lua | 48 ++++++-- spec/forge_spec.lua | 34 +++++- 6 files changed, 220 insertions(+), 124 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index e80349e..90db8ee 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -1525,7 +1525,9 @@ Configuration: ~ Top-level fields: ~ {close} (boolean, default: false) When true, tasks linked to closed/merged remote issues are automatically marked - done on buffer open. + done on buffer open. Only forges with an explicit + per-forge key (e.g. `github = {}`) are checked; + unconfigured forges are skipped entirely. {validate} (boolean, default: false) When true, new or changed forge refs are validated on `:w` by fetching metadata. Logs a warning if the ref is not found, auth fails, or diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 9ec3043..103ba6a 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -89,8 +89,7 @@ local function refs_equal(a, b) if not a or not b then return false end - return a.forge == b.forge and a.owner == b.owner - and a.repo == b.repo and a.number == b.number + return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number end ---@param lines string[] diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 7f76d00..78f6654 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -15,6 +15,9 @@ local log = require('pending.log') ---@field labels? string[] ---@field fetched_at string +---@class pending.ForgeFetchError +---@field kind 'not_found'|'auth'|'network' + ---@class pending.ForgeBackend ---@field name string ---@field shorthand string @@ -24,7 +27,7 @@ local log = require('pending.log') ---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ----@field _warned boolean +---@field _auth 'unknown'|'ok'|'failed' ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' @@ -50,7 +53,7 @@ local _instances_resolved = false ---@param backend pending.ForgeBackend ---@return nil function M.register(backend) - backend._warned = false + backend._auth = 'unknown' table.insert(_backends, backend) _by_name[backend.name] = backend _by_shorthand[backend.shorthand] = backend @@ -63,6 +66,53 @@ function M.backends() return _backends end +---@param forge_name string +---@return boolean +function M.is_configured(forge_name) + local raw = vim.g.pending + if not raw or not raw.forge then + return false + end + return raw.forge[forge_name] ~= nil +end + +---@param backend pending.ForgeBackend +---@param callback fun(ok: boolean) +function M.check_auth(backend, callback) + if backend._auth == 'ok' then + callback(true) + return + end + if backend._auth == 'failed' then + callback(false) + return + end + if vim.fn.executable(backend.cli) == 0 then + backend._auth = 'failed' + local forge_cfg = config.get().forge or {} + if forge_cfg.warn_missing_cli ~= false then + log.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + callback(false) + return + end + vim.system(backend.auth_status_args, { text = true }, function(result) + vim.schedule(function() + if result.code == 0 then + backend._auth = 'ok' + callback(true) + else + backend._auth = 'failed' + local forge_cfg = config.get().forge or {} + if forge_cfg.warn_missing_cli ~= false then + log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)) + end + callback(false) + end + end) + end) +end + function M._reset_instances() _instances_resolved = false _by_shorthand = {} @@ -243,57 +293,28 @@ function M.format_label(ref, cache) return text, hl end ----@class pending.ForgeFetchError ----@field code 'not_found'|'auth'|'cli_missing'|'network' ----@field message string - ---@param ref pending.ForgeRef ---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?) function M.fetch_metadata(ref, callback) local args = M._api_args(ref) - local backend = _by_name[ref.forge] - - if backend and vim.fn.executable(backend.cli) == 0 then - vim.schedule(function() - local forge_cfg = config.get().forge or {} - if forge_cfg.warn_missing_cli ~= false and not backend._warned then - backend._warned = true - log.warn(('%s not installed'):format(backend.cli)) - end - callback(nil, { code = 'cli_missing', message = backend.cli .. ' not installed' }) - end) - return - end - vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then + local kind = 'network' local stderr = result.stderr or '' - local err_code = 'network' ---@type 'not_found'|'auth'|'network' if stderr:find('404') or stderr:find('Not Found') then - err_code = 'not_found' - elseif stderr:find('401') or stderr:find('requires authentication') then - err_code = 'auth' + kind = 'not_found' + elseif stderr:find('401') or stderr:find('403') or stderr:find('auth') then + kind = 'auth' end vim.schedule(function() - local forge_cfg = config.get().forge or {} - if - backend - and forge_cfg.warn_missing_cli ~= false - and not backend._warned - then - backend._warned = true - log.warn( - ('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) - ) - end - callback(nil, { code = err_code, message = stderr }) + callback(nil, { kind = kind }) end) return end local ok, decoded = pcall(vim.json.decode, result.stdout) if not ok or not decoded then vim.schedule(function() - callback(nil) + callback(nil, { kind = 'network' }) end) return end @@ -328,58 +349,105 @@ function M.refresh(s) return end local tasks = s:tasks() - local pending_fetches = 0 - local any_changed = false - local any_fetched = false + local by_forge = {} ---@type table for _, task in ipairs(tasks) do if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then - local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] - pending_fetches = pending_fetches + 1 - M.fetch_metadata(ref, function(cache) - pending_fetches = pending_fetches - 1 - if cache then - task._extra._forge_cache = cache - any_fetched = true - if - forge_cfg.close - and (cache.state == 'closed' or cache.state == 'merged') - and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') - then - task.status = 'done' - task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] - any_changed = true - end - else - task._extra._forge_cache = { - state = 'open', - fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], - } + local fname = task._extra._forge_ref.forge + if not by_forge[fname] then + by_forge[fname] = {} + end + table.insert(by_forge[fname], task) + end + end + local any_work = false + for fname, forge_tasks in pairs(by_forge) do + if M.is_configured(fname) and _by_name[fname] then + any_work = true + M.check_auth(_by_name[fname], function(authed) + if not authed then + return end - if pending_fetches == 0 then - if any_changed then - s:save() - end - local buffer = require('pending.buffer') - if - (any_changed or any_fetched) - and buffer.bufnr() - and vim.api.nvim_buf_is_valid(buffer.bufnr()) - then - buffer.render() - end + local remaining = #forge_tasks + local any_changed = false + local any_fetched = false + for _, task in ipairs(forge_tasks) do + local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] + M.fetch_metadata(ref, function(cache) + remaining = remaining - 1 + if cache then + task._extra._forge_cache = cache + any_fetched = true + if + (cache.state == 'closed' or cache.state == 'merged') + and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') + then + task.status = 'done' + task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] + any_changed = true + end + else + task._extra._forge_cache = { + state = 'open', + fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], + } + end + if remaining == 0 then + if any_changed then + s:save() + end + local buffer = require('pending.buffer') + if + (any_changed or any_fetched) + and buffer.bufnr() + and vim.api.nvim_buf_is_valid(buffer.bufnr()) + then + buffer.render() + end + end + end) end end) end end - if pending_fetches == 0 then + if not any_work then log.info('No linked tasks to refresh.') end end +---@param refs pending.ForgeRef[] +function M.validate_refs(refs) + local by_forge = {} ---@type table + for _, ref in ipairs(refs) do + local fname = ref.forge + if not by_forge[fname] then + by_forge[fname] = {} + end + table.insert(by_forge[fname], ref) + end + for fname, forge_refs in pairs(by_forge) do + if not M.is_configured(fname) or not _by_name[fname] then + goto continue + end + M.check_auth(_by_name[fname], function(authed) + if not authed then + return + end + for _, ref in ipairs(forge_refs) do + M.fetch_metadata(ref, function(_, err) + if err and err.kind == 'not_found' then + log.warn(('%s:%s/%s#%d not found'):format(ref.forge, ref.owner, ref.repo, ref.number)) + end + end) + end + end) + ::continue:: + end +end + ---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string} ---@return pending.ForgeBackend -function M.gitea_backend(opts) +function M.gitea_forge(opts) return { name = opts.name, shorthand = opts.shorthand, @@ -389,7 +457,6 @@ function M.gitea_backend(opts) auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' }, default_icon = opts.default_icon or '', default_issue_format = opts.default_issue_format or '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = @@ -440,7 +507,6 @@ M.register({ auth_status_args = { 'gh', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = @@ -491,7 +557,6 @@ M.register({ auth_status_args = { 'glab', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') @@ -547,7 +612,6 @@ M.register({ auth_status_args = { 'tea', 'login', 'list' }, default_icon = '', default_issue_format = '%i %o/%r#%n', - _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = @@ -588,31 +652,4 @@ M.register({ end, }) ----@param refs pending.ForgeRef[] -function M.validate_refs(refs) - local forge_cfg = config.get().forge or {} - if not forge_cfg.validate then - return - end - for _, ref in ipairs(refs) do - M.fetch_metadata(ref, function(_, err) - if err then - local label = ref.owner .. '/' .. ref.repo .. '#' .. ref.number - local backend = _by_name[ref.forge] - if err.code == 'not_found' then - log.warn(('%s not found — check owner, repo, and number'):format(label)) - elseif err.code == 'auth' then - local cmd = backend and backend.auth_cmd or 'auth' - log.warn(('%s: not authenticated — run `%s`'):format(label, cmd)) - elseif err.code == 'cli_missing' then - local cli = backend and backend.cli or 'cli' - log.warn(('%s: %s not installed'):format(label, cli)) - else - log.warn(('%s: validation failed'):format(label)) - end - end - end) - end -end - return M diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 457eb67..d00031b 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -49,7 +49,9 @@ function M.check() vim.health.start('pending.nvim: forge') local forge = require('pending.forge') for _, backend in ipairs(forge.backends()) do - if vim.fn.executable(backend.cli) == 1 then + if not forge.is_configured(backend.name) then + vim.health.info(('%s: not configured (skipped)'):format(backend.name)) + elseif vim.fn.executable(backend.cli) == 1 then vim.health.ok(('%s found'):format(backend.cli)) else vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index e471e97..791d7f6 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -287,9 +287,19 @@ describe('diff', function() end) it('returns forge refs for changed refs on existing tasks', function() - s:add({ description = 'Fix bug gh:user/repo#1', _extra = { - _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 1, url = '' }, - } }) + s:add({ + description = 'Fix bug gh:user/repo#1', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 1, + url = '', + }, + }, + }) s:save() local lines = { '# Todo', @@ -301,9 +311,19 @@ describe('diff', function() end) it('returns empty when forge ref is unchanged', function() - s:add({ description = 'Fix bug gh:user/repo#42', _extra = { - _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '' }, - } }) + s:add({ + description = 'Fix bug gh:user/repo#42', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, + }, + }) s:save() local lines = { '# Todo', @@ -323,9 +343,19 @@ describe('diff', function() end) it('returns forge refs for duplicated tasks', function() - s:add({ description = 'Fix bug gh:user/repo#42', _extra = { - _forge_ref = { forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '' }, - } }) + s:add({ + description = 'Fix bug gh:user/repo#42', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, + }, + }) s:save() local lines = { '# Todo', diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index 8bd4162..067548e 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -330,7 +330,7 @@ describe('forge registry', function() end) it('register() with custom backend resolves URLs', function() - local custom = forge.gitea_backend({ + local custom = forge.gitea_forge({ name = 'mygitea', shorthand = 'mg', default_host = 'gitea.example.com', @@ -367,8 +367,8 @@ describe('forge registry', function() assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args) end) - it('gitea_backend() creates a working backend', function() - local b = forge.gitea_backend({ + it('gitea_forge() creates a working backend', function() + local b = forge.gitea_forge({ name = 'forgejo', shorthand = 'fj', default_host = 'forgejo.example.com', @@ -396,7 +396,7 @@ describe('custom forge prefixes', function() local complete = require('pending.complete') it('parses custom-length shorthand (3+ chars)', function() - local custom = forge.gitea_backend({ + local custom = forge.gitea_forge({ name = 'customforge', shorthand = 'cgf', default_host = 'custom.example.com', @@ -458,6 +458,32 @@ describe('custom forge prefixes', function() end) end) +describe('is_configured', function() + it('returns false when vim.g.pending is nil', function() + vim.g.pending = nil + assert.is_false(forge.is_configured('github')) + end) + + it('returns false when forge key is absent', function() + vim.g.pending = { forge = { close = true } } + assert.is_false(forge.is_configured('github')) + vim.g.pending = nil + end) + + it('returns true when forge key is present', function() + vim.g.pending = { forge = { github = {} } } + assert.is_true(forge.is_configured('github')) + assert.is_false(forge.is_configured('gitlab')) + vim.g.pending = nil + end) + + it('returns true for non-empty forge config', function() + vim.g.pending = { forge = { gitlab = { icon = '' } } } + assert.is_true(forge.is_configured('gitlab')) + vim.g.pending = nil + end) +end) + describe('forge diff integration', function() local store = require('pending.store') local diff = require('pending.diff') From 46b5d52b608f44e22412207fbaee8c6b41204fde Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:34:17 -0400 Subject: [PATCH 08/26] feat(forge): support bare repo-level forge refs (#135) (#140) Problem: Forge refs required an issue/PR number (`gh:user/repo#42`). Users wanting to link a repo without a specific issue had no option. Solution: Accept `gh:user/repo` shorthand and `https://github.com/user/repo` URLs as `type='repo'` refs with `number=nil`. These conceal and render virtual text like numbered refs but skip all API calls (no validate, no fetch, no close). `format_label` strips `#%n` for bare refs. Omnifunc offers both `owner/repo#` and `owner/repo` completions. Closes #135 --- lua/pending/complete.lua | 12 ++- lua/pending/forge.lua | 220 ++++++++++++++++++++++++--------------- spec/forge_spec.lua | 117 ++++++++++++++++++++- 3 files changed, 261 insertions(+), 88 deletions(-) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 98291ce..26f8798 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -194,9 +194,15 @@ function M.omnifunc(findstart, base) local key = ref.owner .. '/' .. ref.repo if not seen[key] then seen[key] = true - local word = key .. '#' - if base == '' or word:sub(1, #base) == base then - table.insert(matches, { word = word, menu = '[' .. source .. ']' }) + local word_num = key .. '#' + if base == '' or word_num:sub(1, #base) == base then + table.insert(matches, { word = word_num, menu = '[' .. source .. ']' }) + end + if base == '' or key:sub(1, #base) == base then + table.insert( + matches, + { word = key, menu = '[' .. source .. ']', info = 'Bare repo link' } + ) end end end diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 78f6654..6116a9f 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -5,8 +5,8 @@ local log = require('pending.log') ---@field forge string ---@field owner string ---@field repo string ----@field type 'issue'|'pull_request'|'merge_request' ----@field number integer +---@field type 'issue'|'pull_request'|'merge_request'|'repo' +---@field number? integer ---@field url string ---@class pending.ForgeCache @@ -27,7 +27,7 @@ local log = require('pending.log') ---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ----@field _auth 'unknown'|'ok'|'failed' +---@field _auth? 'unknown'|'ok'|'failed' ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' @@ -157,18 +157,35 @@ function M._parse_shorthand(token) return nil end local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$') + if owner then + local num = tonumber(number) --[[@as integer]] + local url = 'https://' + .. backend.default_host + .. '/' + .. owner + .. '/' + .. repo + .. '/issues/' + .. num + return { + forge = backend.name, + owner = owner, + repo = repo, + type = 'issue', + number = num, + url = url, + } + end + owner, repo = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)$') if not owner then return nil end - local num = tonumber(number) --[[@as integer]] - local url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num return { forge = backend.name, owner = owner, repo = repo, - type = 'issue', - number = num, - url = url, + type = 'repo', + url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo, } end @@ -261,7 +278,7 @@ end ---@return string[] function M._api_args(ref) local backend = _by_name[ref.forge] - if not backend then + if not backend or not ref.number then return {} end return backend:api_args(ref) @@ -278,12 +295,15 @@ function M.format_label(ref, cache) local default_icon = backend and backend.default_icon or '' local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n' local fmt = forge_cfg.issue_format or default_fmt + if ref.type == 'repo' then + fmt = fmt:gsub('#?%%n', ''):gsub('%s+$', '') + end local icon = forge_cfg.icon or default_icon local text = fmt :gsub('%%i', icon) :gsub('%%o', ref.owner) :gsub('%%r', ref.repo) - :gsub('%%n', tostring(ref.number)) + :gsub('%%n', ref.number and tostring(ref.number) or '') local hl = 'PendingForge' if cache then if cache.state == 'closed' or cache.state == 'merged' then @@ -296,6 +316,10 @@ end ---@param ref pending.ForgeRef ---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?) function M.fetch_metadata(ref, callback) + if ref.type == 'repo' then + callback(nil) + return + end local args = M._api_args(ref) vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then @@ -351,7 +375,12 @@ function M.refresh(s) local tasks = s:tasks() local by_forge = {} ---@type table for _, task in ipairs(tasks) do - if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then + if + task.status ~= 'deleted' + and task._extra + and task._extra._forge_ref + and task._extra._forge_ref.type ~= 'repo' + then local fname = task._extra._forge_ref.forge if not by_forge[fname] then by_forge[fname] = {} @@ -419,11 +448,15 @@ end function M.validate_refs(refs) local by_forge = {} ---@type table for _, ref in ipairs(refs) do + if ref.type == 'repo' then + goto skip_ref + end local fname = ref.forge if not by_forge[fname] then by_forge[fname] = {} end table.insert(by_forge[fname], ref) + ::skip_ref:: end for fname, forge_refs in pairs(by_forge) do if not M.is_configured(fname) or not _by_name[fname] then @@ -461,25 +494,29 @@ function M.gitea_forge(opts) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = self.name, + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = self.name, + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = self.name, - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(self, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' @@ -511,25 +548,29 @@ M.register({ _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pull') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pull' and 'pull_request' or 'issue' + return { + forge = 'github', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pull' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = 'github', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pull' and 'pull_request' or 'issue' - return { - forge = 'github', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) return { @@ -560,29 +601,38 @@ M.register({ parse_url = function(self, url) _ensure_instances() local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'merge_requests') and _by_host[host] == self then + local owner, repo = path:match('^(.+)/([^/]+)$') + if owner then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } + end end - if kind ~= 'issues' and kind ~= 'merge_requests' then - return nil + host, path = url:match('^https?://([^/]+)/(.+)$') + if host and _by_host[host] == self then + local trimmed = path:gsub('/$', '') + if not trimmed:find('/%-/') then + local owner, repo = trimmed:match('^(.+)/([^/]+)$') + if owner then + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } + end + end end - if _by_host[host] ~= self then - return nil - end - local owner, repo = path:match('^(.+)/([^/]+)$') - if not owner then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' - return { - forge = 'gitlab', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') @@ -616,25 +666,29 @@ M.register({ _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = 'codeberg', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index 067548e..84c812c 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -44,12 +44,30 @@ describe('forge', function() assert.is_nil(forge._parse_shorthand('xx:user/repo#1')) end) - it('rejects missing number', function() - assert.is_nil(forge._parse_shorthand('gh:user/repo')) + it('parses bare gh: shorthand without number', function() + local ref = forge._parse_shorthand('gh:user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + assert.equals('https://github.com/user/repo', ref.url) + end) + + it('parses bare gl: shorthand without number', function() + local ref = forge._parse_shorthand('gl:group/project') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) end) it('rejects missing repo', function() assert.is_nil(forge._parse_shorthand('gh:user#1')) + assert.is_nil(forge._parse_shorthand('gh:user')) end) end) @@ -73,6 +91,23 @@ describe('forge', function() it('rejects non-github URL', function() assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1')) end) + + it('parses bare repo URL', function() + local ref = forge._parse_github_url('https://github.com/user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) + + it('parses bare repo URL with trailing slash', function() + local ref = forge._parse_github_url('https://github.com/user/repo/') + assert.is_not_nil(ref) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('_parse_gitlab_url', function() @@ -98,6 +133,16 @@ describe('forge', function() assert.equals('org/sub', ref.owner) assert.equals('project', ref.repo) end) + + it('parses bare repo URL', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/group/project') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('_parse_codeberg_url', function() @@ -116,6 +161,16 @@ describe('forge', function() assert.is_not_nil(ref) assert.equals('pull_request', ref.type) end) + + it('parses bare repo URL', function() + local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo') + assert.is_not_nil(ref) + assert.equals('codeberg', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('parse_ref', function() @@ -141,6 +196,14 @@ describe('forge', function() assert.is_nil(forge.parse_ref('hello')) assert.is_nil(forge.parse_ref('due:tomorrow')) end) + + it('dispatches bare shorthand', function() + local ref = forge.parse_ref('gh:user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('find_refs', function() @@ -184,6 +247,17 @@ describe('forge', function() assert.equals(0, refs[1].start_byte) assert.equals(8, refs[1].end_byte) end) + + it('finds bare shorthand ref', function() + local refs = forge.find_refs('Fix gh:user/repo') + assert.equals(1, #refs) + assert.equals('github', refs[1].ref.forge) + assert.equals('repo', refs[1].ref.type) + assert.is_nil(refs[1].ref.number) + assert.equals('gh:user/repo', refs[1].raw) + assert.equals(4, refs[1].start_byte) + assert.equals(16, refs[1].end_byte) + end) end) describe('_api_args', function() @@ -262,6 +336,30 @@ describe('forge', function() assert.equals('PendingForgeClosed', hl) end) + it('formats bare repo ref without #N', function() + local text = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'repo', + url = '', + }, nil) + assert.truthy(text:find('user/repo')) + assert.is_nil(text:find('#')) + end) + + it('still formats numbered ref with #N', function() + local text = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, nil) + assert.truthy(text:find('user/repo#42')) + end) + it('uses closed highlight for merged state', function() local _, hl = forge.format_label({ forge = 'gitlab', @@ -542,4 +640,19 @@ describe('forge diff integration', function() assert.equals(1, updated._extra._forge_ref.number) os.remove(tmp) end) + + it('stores bare forge_ref in _extra on new task', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + diff.apply({ '- [ ] Check out gh:user/repo' }, s) + local tasks = s:active_tasks() + assert.equals(1, #tasks) + assert.is_not_nil(tasks[1]._extra) + assert.is_not_nil(tasks[1]._extra._forge_ref) + assert.equals('github', tasks[1]._extra._forge_ref.forge) + assert.equals('repo', tasks[1]._extra._forge_ref.type) + assert.is_nil(tasks[1]._extra._forge_ref.number) + os.remove(tmp) + end) end) From 939251f629a31347cb8a8779dc5d28fbf6ddf511 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:55:36 -0400 Subject: [PATCH 09/26] refactor: tighten LuaCATS annotations and canonicalize metadata fields (#141) * refactor: tighten LuaCATS annotations across modules Problem: type annotations repeated inline unions with no aliases, used `table` where structured types exist, and had loose `string` where union types should be used. Solution: add `pending.TaskStatus`, `pending.RecurMode`, `pending.TaskExtra`, `pending.ForgeType`, `pending.ForgeState`, `pending.ForgeAuthStatus` aliases and `pending.SyncBackend` interface. Replace inline unions and loose types with the new aliases in `store.lua`, `forge.lua`, `config.lua`, `diff.lua`, `views.lua`, `parse.lua`, `init.lua`, and `oauth.lua`. * refactor: canonicalize internal metadata field names Problem: `pending.Metadata` used shorthand field names (`cat`, `rec`, `rec_mode`) matching user-facing token syntax, coupling internal representation to config. `RecurSpec.from_completion` used a boolean where a `pending.RecurMode` alias exists. `category_syntax` was hardcoded to `'cat'` with no config option. Solution: rename `Metadata` fields to `category`/`recur`/`recur_mode`, add `category_syntax` config option (default `'cat'`), rename `ParsedEntry` fields to match, replace `RecurSpec.from_completion` with `mode: pending.RecurMode`, and restore `[string]` indexer on `pending.ForgeConfig` alongside explicit fields. --- doc/pending.txt | 14 +++++++++++--- lua/pending/complete.lua | 10 ++++++---- lua/pending/config.lua | 5 +++++ lua/pending/diff.lua | 30 +++++++++++++++--------------- lua/pending/forge.lua | 12 ++++++++---- lua/pending/init.lua | 27 ++++++++++++++++++++------- lua/pending/parse.lua | 27 +++++++++++++++++---------- lua/pending/recur.lua | 30 +++++++++++++++--------------- lua/pending/store.lua | 24 ++++++++++++++++++------ lua/pending/sync/oauth.lua | 9 +++++++++ lua/pending/views.lua | 2 +- plugin/pending.lua | 12 ++++++++---- spec/diff_spec.lua | 6 +++--- spec/forge_spec.lua | 2 +- spec/parse_spec.lua | 10 +++++----- spec/recur_spec.lua | 4 ++-- 16 files changed, 144 insertions(+), 80 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 90db8ee..7270c8e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -605,9 +605,10 @@ Supported tokens: ~ `cat:Name` Move the task to the named category on save. `rec:` Set a recurrence rule (see |pending-recurrence|). -The token name for due dates defaults to `due` and is configurable via -`date_syntax` in |pending-config|. The token name for recurrence defaults to -`rec` and is configurable via `recur_syntax`. +The token name for categories defaults to `cat` and is configurable via +`category_syntax` in |pending-config|. The token name for due dates defaults +to `due` and is configurable via `date_syntax`. The token name for recurrence +defaults to `rec` and is configurable via `recur_syntax`. Example: > @@ -734,6 +735,7 @@ loads: >lua data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_category = 'Todo', date_format = '%b %d', + category_syntax = 'cat', date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', @@ -817,6 +819,12 @@ Fields: ~ '%m/%d', -- 03/15 (year inferred) } < + {category_syntax} (string, default: 'cat') + The token name for inline category metadata. Change + this to use a different keyword, for example + `'category'` to write `category:Work` instead of + `cat:Work`. + {date_syntax} (string, default: 'due') The token name for inline due-date metadata. Change this to use a different keyword, for example `'by'` diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 26f8798..480a488 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -136,9 +136,11 @@ function M.omnifunc(findstart, base) local dk = date_key() local rk = recur_key() + local ck = config.get().category_syntax or 'cat' + local checks = { { vim.pesc(dk) .. ':([%S]*)$', dk }, - { 'cat:([%S]*)$', 'cat' }, + { vim.pesc(ck) .. ':([%S]*)$', ck }, { vim.pesc(rk) .. ':([%S]*)$', rk }, } for _, b in ipairs(forge.backends()) do @@ -172,10 +174,10 @@ function M.omnifunc(findstart, base) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) end end - elseif source == 'cat' then + elseif source == (config.get().category_syntax or 'cat') then for _, c in ipairs(get_categories()) do if base == '' or c:sub(1, #base) == base then - table.insert(matches, { word = c, menu = '[cat]' }) + table.insert(matches, { word = c, menu = '[' .. source .. ']' }) end end elseif source == rk then @@ -190,7 +192,7 @@ function M.omnifunc(findstart, base) local seen = {} for _, task in ipairs(s:tasks()) do if task._extra and task._extra._forge_ref then - local ref = task._extra._forge_ref + local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] local key = ref.owner .. '/' .. ref.repo if not seen[key] then seen[key] = true diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 2ec13cc..4a5172e 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -43,6 +43,9 @@ ---@field close? boolean ---@field validate? boolean ---@field warn_missing_cli? boolean +---@field github? pending.ForgeInstanceConfig +---@field gitlab? pending.ForgeInstanceConfig +---@field codeberg? pending.ForgeInstanceConfig ---@field [string] pending.ForgeInstanceConfig ---@class pending.SyncConfig @@ -92,6 +95,7 @@ ---@field data_path string ---@field default_category string ---@field date_format string +---@field category_syntax string ---@field date_syntax string ---@field recur_syntax string ---@field someday_date string @@ -113,6 +117,7 @@ local defaults = { data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', default_category = 'Todo', date_format = '%b %d', + category_syntax = 'cat', date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 103ba6a..24645a2 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -7,11 +7,11 @@ local parse = require('pending.parse') ---@field id? integer ---@field description? string ---@field priority? integer ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field due? string ----@field rec? string ----@field rec_mode? string +---@field recur? string +---@field recur_mode? pending.RecurMode ---@field forge_ref? pending.ForgeRef ---@field lnum integer @@ -65,10 +65,10 @@ function M.parse_buffer(lines) description = description, priority = priority, status = status, - category = metadata.cat or current_category or config.get().default_category, + category = metadata.category or current_category or config.get().default_category, due = metadata.due, - rec = metadata.rec, - rec_mode = metadata.rec_mode, + recur = metadata.recur, + recur_mode = metadata.recur_mode, forge_ref = forge_ref, lnum = i, }) @@ -126,8 +126,8 @@ function M.apply(lines, s, hidden_ids) category = entry.category, priority = entry.priority, due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, + recur = entry.recur, + recur_mode = entry.recur_mode, order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) @@ -157,13 +157,13 @@ function M.apply(lines, s, hidden_ids) task.due = entry.due changed = true end - if entry.rec ~= nil then - if task.recur ~= entry.rec then - task.recur = entry.rec + if entry.recur ~= nil then + if task.recur ~= entry.recur then + task.recur = entry.recur changed = true end - if task.recur_mode ~= entry.rec_mode then - task.recur_mode = entry.rec_mode + if task.recur_mode ~= entry.recur_mode then + task.recur_mode = entry.recur_mode changed = true end end @@ -201,8 +201,8 @@ function M.apply(lines, s, hidden_ids) category = entry.category, priority = entry.priority, due = entry.due, - recur = entry.rec, - recur_mode = entry.rec_mode, + recur = entry.recur, + recur_mode = entry.recur_mode, order = order_counter, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 6116a9f..28c173a 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -1,17 +1,21 @@ local config = require('pending.config') local log = require('pending.log') +---@alias pending.ForgeType 'issue'|'pull_request'|'merge_request'|'repo' +---@alias pending.ForgeState 'open'|'closed'|'merged' +---@alias pending.ForgeAuthStatus 'unknown'|'ok'|'failed' + ---@class pending.ForgeRef ---@field forge string ---@field owner string ---@field repo string ----@field type 'issue'|'pull_request'|'merge_request'|'repo' +---@field type pending.ForgeType ---@field number? integer ---@field url string ---@class pending.ForgeCache ---@field title? string ----@field state 'open'|'closed'|'merged' +---@field state pending.ForgeState ---@field labels? string[] ---@field fetched_at string @@ -27,10 +31,10 @@ local log = require('pending.log') ---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ----@field _auth? 'unknown'|'ok'|'failed' +---@field _auth? pending.ForgeAuthStatus ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ----@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' +---@field parse_state fun(self: pending.ForgeBackend, decoded: table): pending.ForgeState ---@class pending.forge local M = {} diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 9b642ed..aeba431 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -984,10 +984,10 @@ function M.add(text) end s:add({ description = description, - category = metadata.cat, + category = metadata.category, due = metadata.due, - recur = metadata.rec, - recur_mode = metadata.rec_mode, + recur = metadata.recur, + recur_mode = metadata.recur_mode, priority = metadata.priority, }) _save_and_notify() @@ -998,6 +998,14 @@ function M.add(text) log.info('Task added: ' .. description) end +---@class pending.SyncBackend +---@field name string +---@field auth fun(): nil +---@field push? fun(): nil +---@field pull? fun(): nil +---@field sync? fun(): nil +---@field health? fun(): nil + ---@type string[]? local _sync_backends = nil @@ -1186,6 +1194,7 @@ end local function parse_edit_token(token) local recur = require('pending.recur') local cfg = require('pending.config').get() + local ck = cfg.category_syntax or 'cat' local dk = cfg.date_syntax or 'due' local rk = cfg.recur_syntax or 'rec' @@ -1201,7 +1210,7 @@ local function parse_edit_token(token) if token == '-due' or token == '-' .. dk then return 'due', vim.NIL, nil end - if token == '-cat' then + if token == '-' .. ck then return 'category', vim.NIL, nil end if token == '-rec' or token == '-' .. rk then @@ -1223,7 +1232,7 @@ local function parse_edit_token(token) 'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.' end - local cat_val = token:match('^cat:(.+)$') + local cat_val = token:match('^' .. vim.pesc(ck) .. ':(.+)$') if cat_val then return 'category', cat_val, nil end @@ -1248,11 +1257,15 @@ local function parse_edit_token(token) .. token .. '. Valid: ' .. dk - .. ':, cat:, ' + .. ':, ' + .. ck + .. ':, ' .. rk .. ':, +!, -!, -' .. dk - .. ', -cat, -' + .. ', -' + .. ck + .. ', -' .. rk end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 5a705ef..c38fa54 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -2,9 +2,9 @@ local config = require('pending.config') ---@class pending.Metadata ---@field due? string ----@field cat? string ----@field rec? string ----@field rec_mode? 'scheduled'|'completion' +---@field category? string +---@field recur? string +---@field recur_mode? pending.RecurMode ---@field priority? integer ---@class pending.parse @@ -107,6 +107,11 @@ local function is_valid_datetime(s) return is_valid_date(date_part) and is_valid_time(time_part) end +---@return string +local function category_key() + return config.get().category_syntax or 'cat' +end + ---@return string local function date_key() return config.get().date_syntax or 'due' @@ -531,8 +536,10 @@ function M.body(text) local metadata = {} local i = #tokens + local ck = category_key() local dk = date_key() local rk = recur_key() + local cat_pattern = '^' .. vim.pesc(ck) .. ':(%S+)$' local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' @@ -562,12 +569,12 @@ function M.body(text) metadata.due = resolved i = i - 1 else - local cat_val = token:match('^cat:(%S+)$') + local cat_val = token:match(cat_pattern) if cat_val then - if metadata.cat then + if metadata.category then break end - metadata.cat = cat_val + metadata.category = cat_val i = i - 1 else local pri_bangs = token:match('^%+(!+)$') @@ -581,19 +588,19 @@ function M.body(text) else local rec_val = token:match(rec_pattern) if rec_val then - if metadata.rec then + if metadata.recur then break end local recur = require('pending.recur') local raw_spec = rec_val if raw_spec:sub(1, 1) == '!' then - metadata.rec_mode = 'completion' + metadata.recur_mode = 'completion' raw_spec = raw_spec:sub(2) end if not recur.validate(raw_spec) then break end - metadata.rec = raw_spec + metadata.recur = raw_spec i = i - 1 else break @@ -624,7 +631,7 @@ function M.command_add(text) local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$') if rest then local desc, meta = M.body(rest) - meta.cat = meta.cat or cat_prefix + meta.category = meta.category or cat_prefix return desc, meta end end diff --git a/lua/pending/recur.lua b/lua/pending/recur.lua index 9c647aa..8891381 100644 --- a/lua/pending/recur.lua +++ b/lua/pending/recur.lua @@ -2,7 +2,7 @@ ---@field freq 'daily'|'weekly'|'monthly'|'yearly' ---@field interval integer ---@field byday? string[] ----@field from_completion boolean +---@field mode pending.RecurMode ---@field _raw? string ---@class pending.recur @@ -10,29 +10,29 @@ local M = {} ---@type table local named = { - daily = { freq = 'daily', interval = 1, from_completion = false }, + daily = { freq = 'daily', interval = 1, mode = 'scheduled' }, weekdays = { freq = 'weekly', interval = 1, byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, - from_completion = false, + mode = 'scheduled', }, - weekly = { freq = 'weekly', interval = 1, from_completion = false }, - biweekly = { freq = 'weekly', interval = 2, from_completion = false }, - monthly = { freq = 'monthly', interval = 1, from_completion = false }, - quarterly = { freq = 'monthly', interval = 3, from_completion = false }, - yearly = { freq = 'yearly', interval = 1, from_completion = false }, - annual = { freq = 'yearly', interval = 1, from_completion = false }, + weekly = { freq = 'weekly', interval = 1, mode = 'scheduled' }, + biweekly = { freq = 'weekly', interval = 2, mode = 'scheduled' }, + monthly = { freq = 'monthly', interval = 1, mode = 'scheduled' }, + quarterly = { freq = 'monthly', interval = 3, mode = 'scheduled' }, + yearly = { freq = 'yearly', interval = 1, mode = 'scheduled' }, + annual = { freq = 'yearly', interval = 1, mode = 'scheduled' }, } ---@param spec string ---@return pending.RecurSpec? function M.parse(spec) - local from_completion = false + local mode = 'scheduled' ---@type pending.RecurMode local s = spec if s:sub(1, 1) == '!' then - from_completion = true + mode = 'completion' s = s:sub(2) end @@ -44,7 +44,7 @@ function M.parse(spec) freq = base.freq, interval = base.interval, byday = base.byday, - from_completion = from_completion, + mode = mode, } end @@ -58,7 +58,7 @@ function M.parse(spec) return { freq = freq_map[unit], interval = num, - from_completion = from_completion, + mode = mode, } end @@ -66,7 +66,7 @@ function M.parse(spec) return { freq = 'daily', interval = 1, - from_completion = from_completion, + mode = mode, _raw = s, } end @@ -134,7 +134,7 @@ end ---@param base_date string ---@param spec string ----@param mode 'scheduled'|'completion' +---@param mode pending.RecurMode ---@return string function M.next_due(base_date, spec, mode) local parsed = M.parse(spec) diff --git a/lua/pending/store.lua b/lua/pending/store.lua index fcf420e..5870fc6 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -1,19 +1,31 @@ local config = require('pending.config') +---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked' +---@alias pending.RecurMode 'scheduled'|'completion' + +---@class pending.TaskExtra +---@field _forge_ref? pending.ForgeRef +---@field _forge_cache? pending.ForgeCache +---@field _gtasks_task_id? string +---@field _gtasks_list_id? string +---@field _gcal_event_id? string +---@field _gcal_calendar_id? string +---@field [string] any + ---@class pending.Task ---@field id integer ---@field description string ----@field status 'pending'|'done'|'deleted'|'wip'|'blocked' +---@field status pending.TaskStatus ---@field category? string ---@field priority integer ---@field due? string ---@field recur? string ----@field recur_mode? 'scheduled'|'completion' +---@field recur_mode? pending.RecurMode ---@field entry string ---@field modified string ---@field end? string ---@field order integer ----@field _extra? table +---@field _extra? pending.TaskExtra ---@class pending.Data ---@field version integer @@ -24,14 +36,14 @@ local config = require('pending.config') ---@class pending.TaskFields ---@field description string ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field priority? integer ---@field due? string ---@field recur? string ----@field recur_mode? string +---@field recur_mode? pending.RecurMode ---@field order? integer ----@field _extra? table +---@field _extra? pending.TaskExtra ---@class pending.Store ---@field path string diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 8e670c1..a49595c 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -24,6 +24,15 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' ---@field config_key string ---@class pending.OAuthClient : pending.OAuthClientOpts +---@field token_path fun(self: pending.OAuthClient): string +---@field resolve_credentials fun(self: pending.OAuthClient): pending.OAuthCredentials +---@field load_tokens fun(self: pending.OAuthClient): pending.OAuthTokens? +---@field save_tokens fun(self: pending.OAuthClient, tokens: pending.OAuthTokens): boolean +---@field refresh_access_token fun(self: pending.OAuthClient, creds: pending.OAuthCredentials, tokens: pending.OAuthTokens): pending.OAuthTokens? +---@field get_access_token fun(self: pending.OAuthClient): string? +---@field setup fun(self: pending.OAuthClient): nil +---@field auth fun(self: pending.OAuthClient, on_complete?: fun(ok: boolean): nil): nil +---@field clear_tokens fun(self: pending.OAuthClient): nil local OAuthClient = {} OAuthClient.__index = OAuthClient diff --git a/lua/pending/views.lua b/lua/pending/views.lua index b1e691e..6fd1739 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -13,7 +13,7 @@ local parse = require('pending.parse') ---@field id? integer ---@field due? string ---@field raw_due? string ----@field status? string +---@field status? pending.TaskStatus ---@field category? string ---@field overdue? boolean ---@field show_category? boolean diff --git a/plugin/pending.lua b/plugin/pending.lua index 084f162..394c064 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -6,18 +6,19 @@ vim.g.loaded_pending = true ---@return string[] local function edit_field_candidates() local cfg = require('pending.config').get() + local ck = cfg.category_syntax or 'cat' local dk = cfg.date_syntax or 'due' local rk = cfg.recur_syntax or 'rec' return { dk .. ':', - 'cat:', + ck .. ':', rk .. ':', '+!', '+!!', '+!!!', '-!', '-' .. dk, - '-cat', + '-' .. ck, '-' .. rk, } end @@ -135,7 +136,9 @@ local function complete_edit(arg_lead, cmd_line) return result end - local cat_prefix = arg_lead:match('^(cat:)(.*)$') + local ck = cfg.category_syntax or 'cat' + + local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$') if cat_prefix then local after_colon = arg_lead:sub(#cat_prefix + 1) local store = require('pending.store') @@ -192,7 +195,8 @@ end, { for _, task in ipairs(s:active_tasks()) do if task.category and not seen[task.category] then seen[task.category] = true - table.insert(candidates, 'cat:' .. task.category) + local ck = (require('pending.config').get().category_syntax or 'cat') + table.insert(candidates, ck .. ':' .. task.category) end end local filtered = {} diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 791d7f6..355d2db 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -71,7 +71,7 @@ describe('diff', function() '/1/- [ ] Take trash out rec:weekly', } local result = diff.parse_buffer(lines) - assert.are.equal('weekly', result[2].rec) + assert.are.equal('weekly', result[2].recur) end) it('extracts rec: with completion mode', function() @@ -80,8 +80,8 @@ describe('diff', function() '/1/- [ ] Water plants rec:!daily', } local result = diff.parse_buffer(lines) - assert.are.equal('daily', result[2].rec) - assert.are.equal('completion', result[2].rec_mode) + assert.are.equal('daily', result[2].recur) + assert.are.equal('completion', result[2].recur_mode) end) it('inline due: token is parsed', function() diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index 84c812c..ab8d5c4 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -404,7 +404,7 @@ describe('forge parse.body integration', function() it('extracts category but keeps forge ref in description', function() local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work') assert.equals('Fix bug gh:user/repo#42', desc) - assert.equals('Work', meta.cat) + assert.equals('Work', meta.category) end) it('leaves non-forge tokens as description', function() diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 0820356..8f1135f 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -31,21 +31,21 @@ describe('parse', function() it('extracts category', function() local desc, meta = parse.body('Buy groceries cat:Errands') assert.are.equal('Buy groceries', desc) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) it('extracts both due and cat', function() local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) it('extracts metadata in any order', function() local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) - assert.are.equal('Errands', meta.cat) + assert.are.equal('Errands', meta.category) end) it('stops at duplicate key', function() @@ -400,7 +400,7 @@ describe('parse', function() it('detects category prefix', function() local desc, meta = parse.command_add('School: Do homework') assert.are.equal('Do homework', desc) - assert.are.equal('School', meta.cat) + assert.are.equal('School', meta.category) end) it('ignores lowercase prefix', function() @@ -411,7 +411,7 @@ describe('parse', function() it('combines category prefix with inline metadata', function() local desc, meta = parse.command_add('School: Do homework due:2026-03-15') assert.are.equal('Do homework', desc) - assert.are.equal('School', meta.cat) + assert.are.equal('School', meta.category) assert.are.equal('2026-03-15', meta.due) end) end) diff --git a/spec/recur_spec.lua b/spec/recur_spec.lua index 53b7478..c072b7b 100644 --- a/spec/recur_spec.lua +++ b/spec/recur_spec.lua @@ -8,7 +8,7 @@ describe('recur', function() local r = recur.parse('daily') assert.are.equal('daily', r.freq) assert.are.equal(1, r.interval) - assert.is_false(r.from_completion) + assert.are.equal('scheduled', r.mode) end) it('parses weekdays', function() @@ -79,7 +79,7 @@ describe('recur', function() it('parses ! prefix as completion-based', function() local r = recur.parse('!weekly') assert.are.equal('weekly', r.freq) - assert.is_true(r.from_completion) + assert.are.equal('completion', r.mode) end) it('parses raw RRULE fragment', function() From c9790ed3bf82037cc78016292377d4d0efbdb011 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:02:55 -0400 Subject: [PATCH 10/26] fix(parse): skip forge refs in right-to-left metadata scan (#142) Problem: `parse.body()` scans tokens right-to-left and breaks on the first non-metadata token. Forge refs like `gl:a/b#12` halted the scan, preventing metadata tokens to their left (e.g. `due:tomorrow`) from being parsed. Additionally, `diff.parse_buffer()` ignored `metadata.priority` from `+!!` tokens and only used checkbox-derived priority, and priority updates between two non-zero values were silently skipped. Solution: Recognize forge ref tokens via `forge.parse_ref()` during the right-to-left scan and skip past them, re-appending them to the description so `forge.find_refs()` still works. Prefer `metadata.priority` over checkbox priority in `parse_buffer()`, and simplify the priority update condition to catch all value changes. --- lua/pending/diff.lua | 7 ++----- lua/pending/parse.lua | 8 ++++++++ spec/diff_spec.lua | 36 ++++++++++++++++++++++++++++++++++++ spec/parse_spec.lua | 28 ++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 24645a2..ac38f7a 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -63,7 +63,7 @@ function M.parse_buffer(lines) type = 'task', id = id and tonumber(id) or nil, description = description, - priority = priority, + priority = metadata.priority or priority, status = status, category = metadata.category or current_category or config.get().default_category, due = metadata.due, @@ -146,10 +146,7 @@ function M.apply(lines, s, hidden_ids) task.category = entry.category changed = true end - if entry.priority == 0 and task.priority > 0 then - task.priority = 0 - changed = true - elseif entry.priority > 0 and task.priority == 0 then + if entry.priority ~= task.priority then task.priority = entry.priority changed = true end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index c38fa54..1b36578 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local forge = require('pending.forge') ---@class pending.Metadata ---@field due? string @@ -543,6 +544,7 @@ function M.body(text) local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' + local forge_indices = {} while i >= 1 do local token = tokens[i] @@ -602,6 +604,9 @@ function M.body(text) end metadata.recur = raw_spec i = i - 1 + elseif forge.parse_ref(token) then + table.insert(forge_indices, i) + i = i - 1 else break end @@ -615,6 +620,9 @@ function M.body(text) for j = 1, i do table.insert(desc_tokens, tokens[j]) end + for fi = #forge_indices, 1, -1 do + table.insert(desc_tokens, tokens[forge_indices[fi]]) + end local description = table.concat(desc_tokens, ' ') return description, metadata diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 355d2db..b69bd5a 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -379,5 +379,41 @@ describe('diff', function() local task = s:get(1) assert.are.equal(0, task.priority) end) + + it('sets priority from +!! token', function() + local lines = { + '# Inbox', + '- [ ] Pay bills +!!', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal(2, task.priority) + end) + + it('updates priority between non-zero values', function() + s:add({ description = 'Task name', priority = 2 }) + s:save() + local lines = { + '# Inbox', + '/1/- [!] Task name', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal(1, task.priority) + end) + + it('parses metadata with forge ref on same line', function() + local lines = { + '# Inbox', + '- [ ] Fix bug due:2026-03-15 gh:user/repo#42', + } + diff.apply(lines, s) + s:load() + local task = s:get(1) + assert.are.equal('2026-03-15', task.due) + assert.is_not_nil(task._extra._forge_ref) + end) end) end) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 8f1135f..aebe0c7 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -110,6 +110,34 @@ describe('parse', function() assert.is_nil(meta.due) assert.truthy(desc:find('due:garbage', 1, true)) end) + + it('parses metadata before a forge ref', function() + local desc, meta = parse.body('Fix bug due:2026-03-15 gh:user/repo#42') + assert.are.equal('2026-03-15', meta.due) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + assert.truthy(desc:find('Fix bug', 1, true)) + end) + + it('parses metadata after a forge ref', function() + local desc, meta = parse.body('Fix bug gh:user/repo#42 due:2026-03-15') + assert.are.equal('2026-03-15', meta.due) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + assert.truthy(desc:find('Fix bug', 1, true)) + end) + + it('parses all metadata around forge ref', function() + local desc, meta = parse.body('Fix bug due:tomorrow gh:user/repo#42 cat:Work') + assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) + assert.are.equal('Work', meta.category) + assert.truthy(desc:find('gh:user/repo#42', 1, true)) + end) + + it('parses forge ref between metadata tokens', function() + local desc, meta = parse.body('Fix bug cat:Work gl:a/b#12 due:2026-03-15') + assert.are.equal('2026-03-15', meta.due) + assert.are.equal('Work', meta.category) + assert.truthy(desc:find('gl:a/b#12', 1, true)) + end) end) describe('parse.resolve_date', function() From d35f34d8e0fbd5cd2711c6a67f11f0a8b402bd1d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:08:29 -0400 Subject: [PATCH 11/26] feat(complete): add metadata completion for `:Pending add` (#144) Problem: `:Pending add` had no tab completion for inline metadata tokens, unlike `:Pending edit` which already completed `due:`, `rec:`, and `cat:` values. Solution: Add `complete_add()` that handles `due:`, `rec:`, and `cat:` prefix matching with the same value sources used by `complete_edit()`, and wire it into the command completion dispatcher. --- plugin/pending.lua | 62 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/plugin/pending.lua b/plugin/pending.lua index 394c064..63caadd 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -80,6 +80,65 @@ local function filter_candidates(lead, candidates) end, candidates) end +---@param arg_lead string +---@return string[] +local function complete_add(arg_lead) + local cfg = require('pending.config').get() + local dk = cfg.date_syntax or 'due' + local rk = cfg.recur_syntax or 'rec' + local ck = cfg.category_syntax or 'cat' + + local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$') + if prefix then + local after_colon = arg_lead:sub(#prefix + 1) + local result = {} + for _, d in ipairs(edit_date_values()) do + if d:find(after_colon, 1, true) == 1 then + table.insert(result, prefix .. d) + end + end + return result + end + + local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$') + if rec_prefix then + local after_colon = arg_lead:sub(#rec_prefix + 1) + local result = {} + for _, p in ipairs(edit_recur_values()) do + if p:find(after_colon, 1, true) == 1 then + table.insert(result, rec_prefix .. p) + end + end + return result + end + + local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$') + if cat_prefix then + local after_colon = arg_lead:sub(#cat_prefix + 1) + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local seen = {} + local cats = {} + for _, task in ipairs(s:active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(cats, task.category) + end + end + table.sort(cats) + local result = {} + for _, c in ipairs(cats) do + if c:find(after_colon, 1, true) == 1 then + table.insert(result, cat_prefix .. c) + end + end + return result + end + + return {} +end + ---@param arg_lead string ---@param cmd_line string ---@return string[] @@ -207,6 +266,9 @@ end, { end return filtered end + if cmd_line:match('^Pending%s+add%s') then + return complete_add(arg_lead) + end if cmd_line:match('^Pending%s+archive%s') then return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' }) end From 9593ab7fe8e139fbaf62e595f3a19b34b00f3c72 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:08 -0400 Subject: [PATCH 12/26] feat(priority): add `g` and `g` visual batch priority mappings (#151) Problem: Incrementing or decrementing priority required operating on one task at a time with ``/``, which is tedious when adjusting multiple tasks. Solution: Add `adjust_priority_visual(delta)` that iterates the visual selection range, updates every task line's priority in one pass, then re-renders once. Exposed as `increment_priority_visual()` / `decrement_priority_visual()` with `g` / `g` defaults, new `` mappings, and config keys `priority_up_visual` / `priority_down_visual`. --- doc/pending.txt | 2 ++ lua/pending/config.lua | 4 +++ lua/pending/init.lua | 67 ++++++++++++++++++++++++++++++++++++++++++ plugin/pending.lua | 10 +++++++ 4 files changed, 83 insertions(+) diff --git a/doc/pending.txt b/doc/pending.txt index 7270c8e..5bc8c37 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -354,6 +354,8 @@ Default buffer-local keys: ~ `O` Insert a new task line above (`open_line_above`) `` Increment priority (clamped at `max_priority`) (`priority_up`) `` Decrement priority (clamped at 0) (`priority_down`) + `g` Increment priority for visual selection (`priority_up_visual`) + `g` Decrement priority for visual selection (`priority_down_visual`) `J` Move task down within its category (`move_down`) `K` Move task up within its category (`move_up`) `zc` Fold the current category section (requires `folding`) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 4a5172e..451aace 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -78,6 +78,8 @@ ---@field move_up? string|false ---@field wip? string|false ---@field blocked? string|false +---@field priority_up_visual? string|false +---@field priority_down_visual? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -157,6 +159,8 @@ local defaults = { blocked = 'gb', priority_up = '', priority_down = '', + priority_up_visual = 'g', + priority_down_visual = 'g', }, sync = {}, forge = { diff --git a/lua/pending/init.lua b/lua/pending/init.lua index aeba431..fe8fd6f 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -402,6 +402,26 @@ function M._setup_buf_mappings(bufnr) end end + ---@type table + local visual_actions = { + priority_up_visual = function() + M.increment_priority_visual() + end, + priority_down_visual = function() + M.decrement_priority_visual() + end, + } + + for name, fn in pairs(visual_actions) do + local key = km[name] + if key and key ~= false then + vim.keymap.set('x', key --[[@as string]], function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', false) + fn() + end, opts) + end + end + local textobj = require('pending.textobj') ---@type table @@ -711,6 +731,53 @@ function M.decrement_priority() adjust_priority(-1) end +---@param delta integer +---@return nil +local function adjust_priority_visual(delta) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local start_row = vim.fn.line("'<") + local end_row = vim.fn.line("'>") + local cursor = vim.api.nvim_win_get_cursor(0) + local meta = buffer.meta() + local s = get_store() + local max = require('pending.config').get().max_priority or 3 + local changed = false + for row = start_row, end_row do + if meta[row] and meta[row].type == 'task' and meta[row].id then + local task = s:get(meta[row].id) + if task then + local new_priority = math.max(0, math.min(max, task.priority + delta)) + if new_priority ~= task.priority then + s:update(meta[row].id, { priority = new_priority }) + changed = true + end + end + end + end + if not changed then + return + end + _save_and_notify() + buffer.render(bufnr) + pcall(vim.api.nvim_win_set_cursor, 0, cursor) +end + +---@return nil +function M.increment_priority_visual() + adjust_priority_visual(1) +end + +---@return nil +function M.decrement_priority_visual() + adjust_priority_visual(-1) +end + ---@return nil function M.prompt_date() local bufnr = buffer.bufnr() diff --git a/plugin/pending.lua b/plugin/pending.lua index 63caadd..62e2e89 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -402,6 +402,16 @@ vim.keymap.set('n', '(pending-priority-down)', function() require('pending').decrement_priority() end) +vim.keymap.set('x', '(pending-priority-up-visual)', function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', false) + require('pending').increment_priority_visual() +end) + +vim.keymap.set('x', '(pending-priority-down-visual)', function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', false) + require('pending').decrement_priority_visual() +end) + vim.keymap.set('n', '(pending-filter)', function() vim.ui.input({ prompt = 'Filter: ' }, function(input) if input then From ea59bbae964229c3eaf974afabf0a2df297e167f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:18 -0400 Subject: [PATCH 13/26] fix(init): preserve cursor column and position in mutation functions (#152) Problem: `toggle_complete()`, `toggle_priority()`, `adjust_priority()`, `toggle_status()`, and `move_task()` captured only the row from `nvim_win_get_cursor` and restored the cursor to column 0 after re-render. Additionally, `toggle_complete()` followed the toggled task to its new sorted position at the bottom of the category, which is disorienting when working through a list of tasks. Solution: Capture both row and column from the cursor, and restore the column in all five functions. For `toggle_complete()`, instead of chasing the task ID after render, clamp the cursor to the original row (or total lines if shorter) and advance to the nearest task line, similar to the `]t` motion in `textobj.lua`. --- lua/pending/init.lua | 45 +++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index fe8fd6f..e21320d 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -544,7 +544,8 @@ function M.toggle_complete() if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -578,11 +579,25 @@ function M.toggle_complete() end _save_and_notify() buffer.render(bufnr) - for lnum, m in ipairs(buffer.meta()) do - if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - break + local new_meta = buffer.meta() + local total = #new_meta + local target = math.min(row, total) + if new_meta[target] and new_meta[target].type == 'task' then + vim.api.nvim_win_set_cursor(0, { target, col }) + else + for r = target, total do + if new_meta[r] and new_meta[r].type == 'task' then + vim.api.nvim_win_set_cursor(0, { r, col }) + return + end end + for r = target, 1, -1 do + if new_meta[r] and new_meta[r].type == 'task' then + vim.api.nvim_win_set_cursor(0, { r, col }) + return + end + end + vim.api.nvim_win_set_cursor(0, { target, col }) end end @@ -654,7 +669,8 @@ function M.toggle_priority() if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -675,7 +691,7 @@ function M.toggle_priority() buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -691,7 +707,8 @@ local function adjust_priority(delta) if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -715,7 +732,7 @@ local function adjust_priority(delta) buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -829,7 +846,8 @@ function M.toggle_status(target_status) if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -852,7 +870,7 @@ function M.toggle_status(target_status) buffer.render(bufnr) for lnum, m in ipairs(buffer.meta()) do if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end @@ -868,7 +886,8 @@ function M.move_task(direction) if not require_saved() then return end - local row = vim.api.nvim_win_get_cursor(0)[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local row, col = cursor[1], cursor[2] local meta = buffer.meta() if not meta[row] or meta[row].type ~= 'task' then return @@ -943,7 +962,7 @@ function M.move_task(direction) for lnum, m in ipairs(buffer.meta()) do if m.id == id then - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + vim.api.nvim_win_set_cursor(0, { lnum, col }) break end end From 283f93eda10d10941b65891e12a35f7a8e30c9d4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:27 -0400 Subject: [PATCH 14/26] feat(views): add `hide_done_categories` config option (#153) Problem: Categories where every task is done still render in the buffer, cluttering the view when entire categories are finished. Solution: Add `view.category.hide_done_categories` (boolean, default false). When enabled, `category_view()` skips categories whose tasks are all done/deleted, returns their IDs as `done_cat_hidden_ids`, and `_on_write` merges those IDs into `hidden_ids` passed to `diff.apply()` so hidden tasks are not mistakenly deleted on `:w`. --- doc/pending.txt | 10 ++++++ lua/pending/buffer.lua | 12 ++++++- lua/pending/config.lua | 2 ++ lua/pending/init.lua | 3 ++ lua/pending/views.lua | 21 +++++++++-- spec/views_spec.lua | 79 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 4 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 5bc8c37..bf515e4 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -879,6 +879,16 @@ Fields: ~ `false` uses Vim's built-in foldtext. Folds only apply to category view. + {hide_done_categories} + (boolean, default: false) + When true, categories where every task is + done (or deleted) are hidden from the + rendered buffer. The tasks remain in the + store and reappear when any task in the + category is un-done or a new pending task + is added. Hidden tasks are protected from + deletion on `:w`. + {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index b731262..0bf3d64 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -27,6 +27,8 @@ local _filter_predicates = {} ---@type table local _hidden_ids = {} ---@type table +local _done_cat_hidden_ids = {} +---@type table local _dirty_rows = {} ---@type boolean local _on_bytes_active = false @@ -74,6 +76,11 @@ function M.hidden_ids() return _hidden_ids end +---@return table +function M.done_cat_hidden_ids() + return _done_cat_hidden_ids +end + ---@param predicates string[] ---@param hidden table ---@return nil @@ -694,10 +701,13 @@ function M.render(bufnr) end local lines, line_meta + _done_cat_hidden_ids = {} if current_view == 'priority' then lines, line_meta = views.priority_view(tasks) else - lines, line_meta = views.category_view(tasks) + local done_cat_hidden + lines, line_meta, done_cat_hidden = views.category_view(tasks) + _done_cat_hidden_ids = done_cat_hidden end if #lines == 0 and #_filter_predicates == 0 then diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 451aace..dfce286 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -84,6 +84,7 @@ ---@class pending.CategoryViewConfig ---@field order? string[] ---@field folding? boolean|pending.FoldingConfig +---@field hide_done_categories? boolean ---@class pending.QueueViewConfig @@ -130,6 +131,7 @@ local defaults = { category = { order = {}, folding = true, + hide_done_categories = false, }, queue = {}, }, diff --git a/lua/pending/init.lua b/lua/pending/init.lua index e21320d..c82aaec 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -509,6 +509,9 @@ function M._on_write(bufnr) if #stack > UNDO_MAX then table.remove(stack, 1) end + for id in pairs(buffer.done_cat_hidden_ids()) do + hidden[id] = true + end local new_refs = diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 6fd1739..1b8e303 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -138,6 +138,7 @@ end ---@param tasks pending.Task[] ---@return string[] lines ---@return pending.LineMeta[] meta +---@return table done_cat_hidden_ids function M.category_view(tasks) local by_cat = {} local cat_order = {} @@ -177,6 +178,9 @@ function M.category_view(tasks) cat_order = ordered end + local hide_done = config.get().view.category.hide_done_categories + local done_cat_hidden = {} ---@type table + for _, cat in ipairs(cat_order) do sort_tasks(by_cat[cat]) sort_tasks(done_by_cat[cat]) @@ -184,12 +188,21 @@ function M.category_view(tasks) local lines = {} local meta = {} + local rendered = 0 - for i, cat in ipairs(cat_order) do - if i > 1 then + for _, cat in ipairs(cat_order) do + if hide_done and #by_cat[cat] == 0 and #done_by_cat[cat] > 0 then + for _, t in ipairs(done_by_cat[cat]) do + done_cat_hidden[t.id] = true + end + goto next_cat + end + + if rendered > 0 then table.insert(lines, '') table.insert(meta, { type = 'blank' }) end + rendered = rendered + 1 table.insert(lines, '# ' .. cat) table.insert(meta, { type = 'header', category = cat }) @@ -220,9 +233,11 @@ function M.category_view(tasks) forge_spans = compute_forge_spans(task, prefix_len), }) end + + ::next_cat:: end - return lines, meta + return lines, meta, done_cat_hidden end ---@param tasks pending.Task[] diff --git a/spec/views_spec.lua b/spec/views_spec.lua index ff8ad93..115eb84 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -280,6 +280,85 @@ describe('views', function() assert.are.equal('# Alpha', headers[1]) assert.are.equal('# Beta', headers[2]) end) + + it('returns empty done_cat_hidden_ids when hide_done_categories is false', function() + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + s:add({ description = 'Active', category = 'Personal' }) + local _, _, done_hidden = views.category_view(s:active_tasks()) + assert.are.same({}, done_hidden) + end) + + it('hides categories with only done tasks when hide_done_categories is true', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { category = { hide_done_categories = true } }, + } + config.reset() + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + s:add({ description = 'Active', category = 'Personal' }) + local lines, meta, done_hidden = views.category_view(s:active_tasks()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal(1, #headers) + assert.are.equal('# Personal', headers[1]) + assert.are.same({ [t1.id] = true }, done_hidden) + end) + + it('shows categories with a mix of done and pending tasks', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { category = { hide_done_categories = true } }, + } + config.reset() + local t1 = s:add({ description = 'Done task', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + s:add({ description = 'Active task', category = 'Work' }) + local lines, meta, done_hidden = views.category_view(s:active_tasks()) + local headers = {} + for i, m in ipairs(meta) do + if m.type == 'header' then + table.insert(headers, lines[i]) + end + end + assert.are.equal(1, #headers) + assert.are.equal('# Work', headers[1]) + assert.are.same({}, done_hidden) + end) + + it('does not insert leading blank line when first category is hidden', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { category = { hide_done_categories = true } }, + } + config.reset() + local t1 = s:add({ description = 'Done task', category = 'Alpha' }) + s:update(t1.id, { status = 'done' }) + s:add({ description = 'Active', category = 'Beta' }) + local lines, meta = views.category_view(s:active_tasks()) + assert.are.equal('header', meta[1].type) + assert.are.equal('# Beta', lines[1]) + end) + + it('returns all done task ids from hidden categories', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { category = { hide_done_categories = true } }, + } + config.reset() + local t1 = s:add({ description = 'Done A', category = 'Work' }) + local t2 = s:add({ description = 'Done B', category = 'Work' }) + s:update(t1.id, { status = 'done' }) + s:update(t2.id, { status = 'done' }) + s:add({ description = 'Active', category = 'Personal' }) + local _, _, done_hidden = views.category_view(s:active_tasks()) + assert.are.same({ [t1.id] = true, [t2.id] = true }, done_hidden) + end) end) describe('priority_view', function() From 969dbd299f3de9fb9505a1ac5e8d30cd9ea346a3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:19:36 -0400 Subject: [PATCH 15/26] feat(views): make queue view sort order configurable (#154) Problem: the queue/priority view sort in `sort_tasks_priority()` uses a hardcoded tiebreak chain (status, priority, due, order, id). Users who care more about due dates than priority have no way to reorder it. Solution: add `view.queue.sort` config field (string[]) that defines an ordered tiebreak chain. `build_queue_comparator()` maps each key to a comparison function and returns a single comparator. Unknown keys emit a `log.warn`. The default matches the previous hardcoded behavior. --- doc/pending.txt | 37 ++++++++++++++++---- lua/pending/config.lua | 5 ++- lua/pending/views.lua | 61 ++++++++++++++++++++++++++++++--- spec/views_spec.lua | 78 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 12 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index bf515e4..5cca48e 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -542,11 +542,12 @@ Category view (default): ~ *pending-view-category* `zc` and `zo`. Queue view: ~ *pending-view-queue* - A flat list of all tasks sorted by status (wip → pending → blocked → - done), then by priority, then by due date (tasks without a due date sort - last), then by internal order. Category names are shown as right-aligned virtual - text alongside the due date virtual text so tasks remain identifiable - across categories. The buffer is named `pending://queue`. + A flat list of all tasks sorted by a configurable tiebreak chain + (default: status → priority → due → order → id). See + `view.queue.sort` in |pending-config| for customization. Category + names are shown as right-aligned virtual text alongside the due date + virtual text so tasks remain identifiable across categories. The + buffer is named `pending://queue`. ============================================================================== FILTERS *pending-filters* @@ -749,7 +750,9 @@ loads: >lua order = {}, folding = true, }, - queue = {}, + queue = { + sort = { 'status', 'priority', 'due', 'order', 'id' }, + }, }, keymaps = { close = 'q', @@ -891,6 +894,24 @@ Fields: ~ {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. + {sort} (string[], default: + `{ 'status', 'priority', 'due', + 'order', 'id' }`) + Ordered tiebreak chain for the + queue view sort. Each element is a + sort key; the comparator walks the + list and returns on the first + non-equal comparison. Valid keys: + `status` wip < pending < + blocked < done + `priority` higher number first + `due` sooner first, no-due + last + `order` ascending + `id` ascending + `age` alias for `id` + Unknown keys are ignored with a + warning. Examples: >lua vim.g.pending = { @@ -901,6 +922,10 @@ Fields: ~ order = { 'Work', 'Personal' }, folding = { foldtext = '%c: %n items' }, }, + queue = { + sort = { 'status', 'due', 'priority', + 'order', 'id' }, + }, }, } < diff --git a/lua/pending/config.lua b/lua/pending/config.lua index dfce286..71b0bc5 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -87,6 +87,7 @@ ---@field hide_done_categories? boolean ---@class pending.QueueViewConfig +---@field sort? string[] ---@class pending.ViewConfig ---@field default? 'category'|'priority' @@ -133,7 +134,9 @@ local defaults = { folding = true, hide_done_categories = false, }, - queue = {}, + queue = { + sort = { 'status', 'priority', 'due', 'order', 'id' }, + }, }, keymaps = { close = 'q', diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 1b8e303..c31879e 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -106,17 +106,23 @@ local function sort_tasks(tasks) end) end ----@param tasks pending.Task[] -local function sort_tasks_priority(tasks) - table.sort(tasks, function(a, b) +---@type table +local sort_key_comparators = { + status = 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 + return nil + end, + priority = function(a, b) if a.priority ~= b.priority then return a.priority > b.priority end + return nil + end, + due = function(a, b) local a_due = a.due or '' local b_due = b.due or '' if a_due ~= b_due then @@ -128,11 +134,56 @@ local function sort_tasks_priority(tasks) end return a_due < b_due end + return nil + end, + order = function(a, b) if a.order ~= b.order then return a.order < b.order end - return a.id < b.id - end) + return nil + end, + id = function(a, b) + if a.id ~= b.id then + return a.id < b.id + end + return nil + end, + age = function(a, b) + if a.id ~= b.id then + return a.id < b.id + end + return nil + end, +} + +---@return fun(a: pending.Task, b: pending.Task): boolean +local function build_queue_comparator() + local log = require('pending.log') + local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' } + local comparators = {} + for _, key in ipairs(keys) do + local cmp = sort_key_comparators[key] + if cmp then + table.insert(comparators, cmp) + else + log.warn('unknown queue sort key: ' .. key) + end + end + return function(a, b) + for _, cmp in ipairs(comparators) do + local result = cmp(a, b) + if result ~= nil then + return result + end + end + return false + end +end + +---@param tasks pending.Task[] +local function sort_tasks_priority(tasks) + local cmp = build_queue_comparator() + table.sort(tasks, cmp) end ---@param tasks pending.Task[] diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 115eb84..1305afa 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -529,5 +529,83 @@ describe('views', function() end assert.is_nil(task_meta.recur) end) + + it('sorts by due before priority when sort config is reordered', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'status', 'due', 'priority', 'order', 'id' } } }, + } + config.reset() + s:add({ description = 'High no due', category = 'Work', priority = 2 }) + s:add({ description = 'Low with due', category = 'Work', priority = 0, due = '2050-01-01' }) + local lines, meta = views.priority_view(s:active_tasks()) + local due_row, nodue_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Low with due') then + due_row = i + elseif lines[i]:find('High no due') then + nodue_row = i + end + end + end + assert.is_true(due_row < nodue_row) + end) + + it('uses default sort when config sort is nil', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = {} }, + } + config.reset() + s:add({ description = 'Low', category = 'Work', priority = 0 }) + s:add({ description = 'High', category = 'Work', priority = 1 }) + local lines, meta = views.priority_view(s:active_tasks()) + local high_row, low_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('High') then + high_row = i + elseif lines[i]:find('Low') then + low_row = i + end + end + end + assert.is_true(high_row < low_row) + end) + + it('ignores unknown sort keys with a warning', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'bogus', 'status', 'id' } } }, + } + config.reset() + s:add({ description = 'A', category = 'Work' }) + s:add({ description = 'B', category = 'Work' }) + local lines = views.priority_view(s:active_tasks()) + assert.is_true(#lines == 2) + end) + + it('supports age sort key as alias for id', function() + vim.g.pending = { + data_path = tmpdir .. '/tasks.json', + view = { queue = { sort = { 'age' } } }, + } + config.reset() + s:add({ description = 'Older', category = 'Work' }) + s:add({ description = 'Newer', category = 'Work' }) + local lines, meta = views.priority_view(s:active_tasks()) + local older_row, newer_row + for i, m in ipairs(meta) do + if m.type == 'task' then + if lines[i]:find('Older') then + older_row = i + elseif lines[i]:find('Newer') then + newer_row = i + end + end + end + assert.is_true(older_row < newer_row) + end) end) end) From b2456580b58da6e37a79776d74036e0a65eb0db8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Mar 2026 20:21:26 -0400 Subject: [PATCH 16/26] ci: format --- lua/pending/init.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index c82aaec..529954b 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -416,7 +416,11 @@ function M._setup_buf_mappings(bufnr) local key = km[name] if key and key ~= false then vim.keymap.set('x', key --[[@as string]], function() - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', false) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('', true, false, true), + 'nx', + false + ) fn() end, opts) end From 5ab0aa78a1fbf17b3944271adf12364e8b665f81 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Mar 2026 20:41:32 -0400 Subject: [PATCH 17/26] Revert "feat(views): add `hide_done_categories` config option (#153)" This reverts commit 283f93eda10d10941b65891e12a35f7a8e30c9d4. --- doc/pending.txt | 10 ------ lua/pending/buffer.lua | 12 +------ lua/pending/config.lua | 2 -- lua/pending/init.lua | 3 -- lua/pending/views.lua | 21 ++--------- spec/views_spec.lua | 79 ------------------------------------------ 6 files changed, 4 insertions(+), 123 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 5cca48e..ee16bc8 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -882,16 +882,6 @@ Fields: ~ `false` uses Vim's built-in foldtext. Folds only apply to category view. - {hide_done_categories} - (boolean, default: false) - When true, categories where every task is - done (or deleted) are hidden from the - rendered buffer. The tasks remain in the - store and reappear when any task in the - category is un-done or a new pending task - is added. Hidden tasks are protected from - deletion on `:w`. - {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. {sort} (string[], default: diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 0bf3d64..b731262 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -27,8 +27,6 @@ local _filter_predicates = {} ---@type table local _hidden_ids = {} ---@type table -local _done_cat_hidden_ids = {} ----@type table local _dirty_rows = {} ---@type boolean local _on_bytes_active = false @@ -76,11 +74,6 @@ function M.hidden_ids() return _hidden_ids end ----@return table -function M.done_cat_hidden_ids() - return _done_cat_hidden_ids -end - ---@param predicates string[] ---@param hidden table ---@return nil @@ -701,13 +694,10 @@ function M.render(bufnr) end local lines, line_meta - _done_cat_hidden_ids = {} if current_view == 'priority' then lines, line_meta = views.priority_view(tasks) else - local done_cat_hidden - lines, line_meta, done_cat_hidden = views.category_view(tasks) - _done_cat_hidden_ids = done_cat_hidden + lines, line_meta = views.category_view(tasks) end if #lines == 0 and #_filter_predicates == 0 then diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 71b0bc5..171dd1a 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -84,7 +84,6 @@ ---@class pending.CategoryViewConfig ---@field order? string[] ---@field folding? boolean|pending.FoldingConfig ----@field hide_done_categories? boolean ---@class pending.QueueViewConfig ---@field sort? string[] @@ -132,7 +131,6 @@ local defaults = { category = { order = {}, folding = true, - hide_done_categories = false, }, queue = { sort = { 'status', 'priority', 'due', 'order', 'id' }, diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 529954b..8f1b9e4 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -513,9 +513,6 @@ function M._on_write(bufnr) if #stack > UNDO_MAX then table.remove(stack, 1) end - for id in pairs(buffer.done_cat_hidden_ids()) do - hidden[id] = true - end local new_refs = diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index c31879e..f7dce4a 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -189,7 +189,6 @@ end ---@param tasks pending.Task[] ---@return string[] lines ---@return pending.LineMeta[] meta ----@return table done_cat_hidden_ids function M.category_view(tasks) local by_cat = {} local cat_order = {} @@ -229,9 +228,6 @@ function M.category_view(tasks) cat_order = ordered end - local hide_done = config.get().view.category.hide_done_categories - local done_cat_hidden = {} ---@type table - for _, cat in ipairs(cat_order) do sort_tasks(by_cat[cat]) sort_tasks(done_by_cat[cat]) @@ -239,21 +235,12 @@ function M.category_view(tasks) local lines = {} local meta = {} - local rendered = 0 - for _, cat in ipairs(cat_order) do - if hide_done and #by_cat[cat] == 0 and #done_by_cat[cat] > 0 then - for _, t in ipairs(done_by_cat[cat]) do - done_cat_hidden[t.id] = true - end - goto next_cat - end - - if rendered > 0 then + for i, cat in ipairs(cat_order) do + if i > 1 then table.insert(lines, '') table.insert(meta, { type = 'blank' }) end - rendered = rendered + 1 table.insert(lines, '# ' .. cat) table.insert(meta, { type = 'header', category = cat }) @@ -284,11 +271,9 @@ function M.category_view(tasks) forge_spans = compute_forge_spans(task, prefix_len), }) end - - ::next_cat:: end - return lines, meta, done_cat_hidden + return lines, meta end ---@param tasks pending.Task[] diff --git a/spec/views_spec.lua b/spec/views_spec.lua index 1305afa..e841deb 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -280,85 +280,6 @@ describe('views', function() assert.are.equal('# Alpha', headers[1]) assert.are.equal('# Beta', headers[2]) end) - - it('returns empty done_cat_hidden_ids when hide_done_categories is false', function() - local t1 = s:add({ description = 'Done task', category = 'Work' }) - s:update(t1.id, { status = 'done' }) - s:add({ description = 'Active', category = 'Personal' }) - local _, _, done_hidden = views.category_view(s:active_tasks()) - assert.are.same({}, done_hidden) - end) - - it('hides categories with only done tasks when hide_done_categories is true', function() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - view = { category = { hide_done_categories = true } }, - } - config.reset() - local t1 = s:add({ description = 'Done task', category = 'Work' }) - s:update(t1.id, { status = 'done' }) - s:add({ description = 'Active', category = 'Personal' }) - local lines, meta, done_hidden = views.category_view(s:active_tasks()) - local headers = {} - for i, m in ipairs(meta) do - if m.type == 'header' then - table.insert(headers, lines[i]) - end - end - assert.are.equal(1, #headers) - assert.are.equal('# Personal', headers[1]) - assert.are.same({ [t1.id] = true }, done_hidden) - end) - - it('shows categories with a mix of done and pending tasks', function() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - view = { category = { hide_done_categories = true } }, - } - config.reset() - local t1 = s:add({ description = 'Done task', category = 'Work' }) - s:update(t1.id, { status = 'done' }) - s:add({ description = 'Active task', category = 'Work' }) - local lines, meta, done_hidden = views.category_view(s:active_tasks()) - local headers = {} - for i, m in ipairs(meta) do - if m.type == 'header' then - table.insert(headers, lines[i]) - end - end - assert.are.equal(1, #headers) - assert.are.equal('# Work', headers[1]) - assert.are.same({}, done_hidden) - end) - - it('does not insert leading blank line when first category is hidden', function() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - view = { category = { hide_done_categories = true } }, - } - config.reset() - local t1 = s:add({ description = 'Done task', category = 'Alpha' }) - s:update(t1.id, { status = 'done' }) - s:add({ description = 'Active', category = 'Beta' }) - local lines, meta = views.category_view(s:active_tasks()) - assert.are.equal('header', meta[1].type) - assert.are.equal('# Beta', lines[1]) - end) - - it('returns all done task ids from hidden categories', function() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - view = { category = { hide_done_categories = true } }, - } - config.reset() - local t1 = s:add({ description = 'Done A', category = 'Work' }) - local t2 = s:add({ description = 'Done B', category = 'Work' }) - s:update(t1.id, { status = 'done' }) - s:update(t2.id, { status = 'done' }) - s:add({ description = 'Active', category = 'Personal' }) - local _, _, done_hidden = views.category_view(s:active_tasks()) - assert.are.same({ [t1.id] = true, [t2.id] = true }, done_hidden) - end) end) describe('priority_view', function() From 4a37cb64e4e2cf419f28843493c2c1e941611007 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:47:04 -0400 Subject: [PATCH 18/26] fix(views): pluralize unknown queue sort key warning (#157) Problem: multiple unknown sort keys each triggered a separate warning. Solution: collect unknown keys and emit a single warning with the correct singular/plural label, joined by `, `. --- lua/pending/views.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index f7dce4a..12cbbc0 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -161,14 +161,19 @@ local function build_queue_comparator() local log = require('pending.log') local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' } local comparators = {} + local unknown = {} for _, key in ipairs(keys) do local cmp = sort_key_comparators[key] if cmp then table.insert(comparators, cmp) else - log.warn('unknown queue sort key: ' .. key) + table.insert(unknown, key) end end + if #unknown > 0 then + local label = #unknown == 1 and 'unknown queue sort key: ' or 'unknown queue sort keys: ' + log.warn(label .. table.concat(unknown, ', ')) + end return function(a, b) for _, cmp in ipairs(comparators) do local result = cmp(a, b) From 7c3ba31c43ae25dfd1f3dea4e467fb964dcdf436 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:55:21 -0400 Subject: [PATCH 19/26] feat: add `cancelled` task status with configurable state chars (#158) Problem: the task lifecycle only has `pending`, `wip`, `blocked`, and `done`. There is no way to mark a task as abandoned. Additionally, state characters (`>`, `=`) are hardcoded rather than reading from `config.icons`, so customizing them has no effect on rendering or parsing. Solution: add a `cancelled` status with default state char `c`, `g/` keymap, `PendingCancelled` highlight, filter predicate, and archive support. Unify state chars by making `state_char()`, `parse_buffer()`, and `infer_status()` read from `config.icons`. Change defaults to mnemonic chars: `w` (wip), `b` (blocked), `c` (cancelled). --- doc/pending.txt | 45 ++++++++++++++++++++++++++++----------- lua/pending/buffer.lua | 24 ++++++++++++++++----- lua/pending/config.lua | 8 +++++-- lua/pending/diff.lua | 15 +++++++------ lua/pending/init.lua | 18 +++++++++++----- lua/pending/store.lua | 4 ++-- lua/pending/sync/gcal.lua | 1 + lua/pending/views.lua | 33 ++++++++++++++++++---------- plugin/pending.lua | 6 +++++- 9 files changed, 109 insertions(+), 45 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index ee16bc8..83d09b8 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -347,6 +347,7 @@ Default buffer-local keys: ~ `gr` Prompt for a recurrence pattern (`recur`) `gw` Toggle work-in-progress status (`wip`) `gb` Toggle blocked status (`blocked`) + `g/` Toggle cancelled status (`cancelled`) `gf` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) `gz` Undo the last `:w` save (`undo`) @@ -470,6 +471,12 @@ old keys to `false`: >lua Toggle blocked status for the task under the cursor. If the task is already `blocked`, reverts to `pending`. + *(pending-cancelled)* +(pending-cancelled) + Toggle cancelled status for the task under the cursor. + If the task is already `cancelled`, reverts to `pending`. + Toggling on a `done` task switches it to `cancelled`. + *(pending-priority-up)* (pending-priority-up) Increment the priority level for the task under the cursor, clamped @@ -537,14 +544,15 @@ Category view (default): ~ *pending-view-category* Tasks are grouped under their category header. Categories appear in the order tasks were added unless `category_order` is set (see |pending-config|). Blank lines separate categories. Within each category, - tasks are sorted by status (wip → pending → blocked → done), then by + tasks are sorted by status (wip → pending → blocked → done → cancelled), then by priority, then by insertion order. Category sections are foldable with `zc` and `zo`. Queue view: ~ *pending-view-queue* A flat list of all tasks sorted by a configurable tiebreak chain (default: status → priority → due → order → id). See - `view.queue.sort` in |pending-config| for customization. Category + `view.queue.sort` in |pending-config| for customization. Status + order: wip → pending → blocked → done → cancelled. Category names are shown as right-aligned virtual text alongside the due date virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. @@ -581,6 +589,8 @@ Available predicates: ~ `blocked` Show only tasks with status `blocked`. + `cancelled` Show only tasks with status `cancelled`. + `clear` Special value for |:Pending-filter| — clears the active filter and shows all tasks. @@ -778,6 +788,7 @@ loads: >lua move_up = 'K', wip = 'gw', blocked = 'gb', + cancelled = 'g/', }, sync = { gcal = {}, @@ -957,17 +968,21 @@ Fields: ~ See |pending-gcal|, |pending-gtasks|, |pending-s3|. {icons} (table) *pending.Icons* - Icon characters displayed in the buffer. The - {pending}, {done}, {priority}, {wip}, and - {blocked} characters appear inside brackets - (`[icon]`) as an overlay on the checkbox. The - {category} character prefixes both header lines - and EOL category labels. Fields: + Icon characters used for rendering and parsing + task checkboxes. The {pending}, {done}, + {priority}, {wip}, {blocked}, and {cancelled} + characters determine what is written inside + brackets (`[icon]`) in the buffer text and how + status is inferred on `:w`. Each must be a + single character. The {category} character + prefixes header lines and EOL category labels. + Fields: {pending} Pending task character. Default: ' ' {done} Done task character. Default: 'x' {priority} Priority task character. Default: '!' - {wip} Work-in-progress character. Default: '>' - {blocked} Blocked task character. Default: '=' + {wip} Work-in-progress character. Default: 'w' + {blocked} Blocked task character. Default: 'b' + {cancelled} Cancelled task character. Default: 'c' {due} Due date prefix. Default: '.' {recur} Recurrence prefix. Default: '~' {category} Category prefix. Default: '#' @@ -1024,6 +1039,10 @@ PendingWip Applied to the checkbox icon of work-in-progress tasks. PendingBlocked Applied to the checkbox icon and text of blocked tasks. Default: links to `DiagnosticError`. + *PendingCancelled* +PendingCancelled Applied to the checkbox icon and text of cancelled tasks. + Default: links to `NonText`. + *PendingPriority* PendingPriority Applied to the checkbox icon of priority 1 tasks. Default: links to `DiagnosticWarn`. @@ -1593,8 +1612,8 @@ with cached data and updates extmarks when the fetch completes. State pull: ~ Requires `forge.close = true`. After fetching, if the remote issue/PR -is closed or merged and the local task is pending/wip/blocked, the task is -automatically marked as done. Disabled by default. One-way: local status +is closed or merged and the local task is pending/wip/blocked (not cancelled), +the task is automatically marked as done. Disabled by default. One-way: local status changes do not push back to the forge. Highlight groups: ~ @@ -1622,7 +1641,7 @@ Task fields: ~ {id} (integer) Unique, auto-incrementing task identifier. {description} (string) Task text as shown in the buffer. {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, - or `'deleted'`. + `'cancelled'`, or `'deleted'`. {category} (string) Category name. Defaults to `default_category`. {priority} (integer) Priority level: `0` (none), `1`–`3` (or up to `max_priority`). Higher values sort first. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index b731262..403205d 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -139,6 +139,14 @@ local function apply_inline_row(bufnr, row, m, icons) hl_group = 'PendingDone', invalidate = true, }) + elseif m.status == 'cancelled' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { + end_col = #line, + hl_group = 'PendingCancelled', + invalidate = true, + }) elseif m.status == 'blocked' then local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 @@ -153,10 +161,12 @@ local function apply_inline_row(bufnr, row, m, icons) local icon, icon_hl if m.status == 'done' then icon, icon_hl = icons.done, 'PendingDone' + elseif m.status == 'cancelled' then + icon, icon_hl = icons.cancelled, 'PendingCancelled' elseif m.status == 'wip' then - icon, icon_hl = icons.wip or '>', 'PendingWip' + icon, icon_hl = icons.wip, 'PendingWip' elseif m.status == 'blocked' then - icon, icon_hl = icons.blocked or '=', 'PendingBlocked' + icon, icon_hl = icons.blocked, 'PendingBlocked' elseif m.priority and m.priority >= 3 then icon, icon_hl = icons.priority, 'PendingPriority3' elseif m.priority and m.priority == 2 then @@ -209,11 +219,14 @@ local function infer_status(line) if not ch then return nil end - if ch == 'x' then + local icons = config.get().icons + if ch == icons.done then return 'done' - elseif ch == '>' then + elseif ch == icons.cancelled then + return 'cancelled' + elseif ch == icons.wip then return 'wip' - elseif ch == '=' then + elseif ch == icons.blocked then return 'blocked' end return 'pending' @@ -566,6 +579,7 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingCancelled', { link = 'NonText', default = true }) vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true }) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 171dd1a..368cf21 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -11,6 +11,7 @@ ---@field priority string ---@field wip string ---@field blocked string +---@field cancelled string ---@field due string ---@field recur string ---@field category string @@ -80,6 +81,7 @@ ---@field blocked? string|false ---@field priority_up_visual? string|false ---@field priority_down_visual? string|false +---@field cancelled? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -160,6 +162,7 @@ local defaults = { move_up = 'K', wip = 'gw', blocked = 'gb', + cancelled = 'g/', priority_up = '', priority_down = '', priority_up_visual = 'g', @@ -190,8 +193,9 @@ local defaults = { pending = ' ', done = 'x', priority = '!', - wip = '>', - blocked = '=', + wip = 'w', + blocked = 'b', + cancelled = 'c', due = '.', recur = '~', category = '#', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index ac38f7a..fd00c0e 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -43,14 +43,17 @@ function M.parse_buffer(lines) table.insert(result, { type = 'blank', lnum = i }) elseif id or body then local stripped = body:match('^- %[.?%] (.*)$') or body - local state_char = body:match('^- %[(.-)%]') or ' ' - local priority = state_char == '!' and 1 or 0 + local icons = config.get().icons + local state_char = body:match('^- %[(.-)%]') or icons.pending + local priority = state_char == icons.priority and 1 or 0 local status - if state_char == 'x' then + if state_char == icons.done then status = 'done' - elseif state_char == '>' then + elseif state_char == icons.cancelled then + status = 'cancelled' + elseif state_char == icons.wip then status = 'wip' - elseif state_char == '=' then + elseif state_char == icons.blocked then status = 'blocked' else status = 'pending' @@ -177,7 +180,7 @@ function M.apply(lines, s, hidden_ids) end if entry.status and task.status ~= entry.status then task.status = entry.status - if entry.status == 'done' then + if entry.status == 'done' or entry.status == 'cancelled' then task['end'] = now else task['end'] = nil diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 8f1b9e4..39c0bae 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -47,7 +47,7 @@ function M._recompute_counts() local today_str = os.date('%Y-%m-%d') --[[@as string]] for _, task in ipairs(get_store():active_tasks()) do - if task.status ~= 'done' and task.status ~= 'deleted' then + if task.status ~= 'done' and task.status ~= 'deleted' and task.status ~= 'cancelled' then pending = pending + 1 if task.priority > 0 then priority = priority + 1 @@ -173,6 +173,11 @@ local function compute_hidden_ids(tasks, predicates) visible = false break end + elseif pred == 'cancelled' then + if task.status ~= 'cancelled' then + visible = false + break + end end end if not visible then @@ -368,6 +373,9 @@ function M._setup_buf_mappings(bufnr) blocked = function() M.toggle_status('blocked') end, + cancelled = function() + M.toggle_status('cancelled') + end, priority_up = function() M.increment_priority() end, @@ -840,7 +848,7 @@ function M.prompt_date() end) end ----@param target_status 'wip'|'blocked' +---@param target_status 'wip'|'blocked'|'cancelled' ---@return nil function M.toggle_status(target_status) local bufnr = buffer.bufnr() @@ -866,7 +874,7 @@ function M.toggle_status(target_status) return end if task.status == target_status then - s:update(id, { status = 'pending' }) + s:update(id, { status = 'pending', ['end'] = vim.NIL }) else s:update(id, { status = target_status }) end @@ -1184,7 +1192,7 @@ function M.archive(arg) log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) local count = 0 for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted') and task['end'] then + if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then if task['end'] < cutoff then count = count + 1 end @@ -1205,7 +1213,7 @@ function M.archive(arg) function() local kept = {} for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted') and task['end'] then + if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then if task['end'] < cutoff then goto skip end diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 5870fc6..7c43c0d 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -1,6 +1,6 @@ local config = require('pending.config') ----@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked' +---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'|'cancelled' ---@alias pending.RecurMode 'scheduled'|'completion' ---@class pending.TaskExtra @@ -331,7 +331,7 @@ function Store:update(id, fields) end end task.modified = now - if fields.status == 'done' or fields.status == 'deleted' then + if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then task['end'] = task['end'] or now end return task diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index a0a7617..811105e 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -177,6 +177,7 @@ function M.push() and ( task.status == 'done' or task.status == 'deleted' + or task.status == 'cancelled' or (task.status == 'pending' and not task.due) ) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 12cbbc0..4321e64 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -71,21 +71,24 @@ local function compute_forge_spans(task, prefix_len) end ---@type table -local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } +local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3, cancelled = 4 } ---@param task pending.Task ---@return string local function state_char(task) + local icons = config.get().icons if task.status == 'done' then - return 'x' + return icons.done + elseif task.status == 'cancelled' then + return icons.cancelled elseif task.status == 'wip' then - return '>' + return icons.wip elseif task.status == 'blocked' then - return '=' + return icons.blocked elseif task.priority > 0 then - return '!' + return icons.priority end - return ' ' + return icons.pending end ---@param tasks pending.Task[] @@ -208,7 +211,7 @@ function M.category_view(tasks) by_cat[cat] = {} done_by_cat[cat] = {} end - if task.status == 'done' or task.status == 'deleted' then + if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then table.insert(done_by_cat[cat], task) else table.insert(by_cat[cat], task) @@ -271,7 +274,11 @@ function M.category_view(tasks) status = task.status, category = cat, 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.status ~= 'cancelled' + and task.due ~= nil + and parse.is_overdue(task.due) + or nil, recur = task.recur, forge_spans = compute_forge_spans(task, prefix_len), }) @@ -289,7 +296,7 @@ function M.priority_view(tasks) local done = {} for _, task in ipairs(tasks) do - if task.status == 'done' then + if task.status == 'done' or task.status == 'cancelled' then table.insert(done, task) else table.insert(pending, task) @@ -312,7 +319,7 @@ function M.priority_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') + local state = state_char(task) local line = prefix .. '- [' .. state .. '] ' .. task.description local prefix_len = #prefix + #('- [' .. state .. '] ') table.insert(lines, line) @@ -324,7 +331,11 @@ function M.priority_view(tasks) 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, + overdue = task.status ~= 'done' + and task.status ~= 'cancelled' + 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, diff --git a/plugin/pending.lua b/plugin/pending.lua index 62e2e89..d9420c6 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -246,7 +246,7 @@ end, { used[word] = true end local candidates = - { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } + { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked', 'cancelled' } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() @@ -394,6 +394,10 @@ vim.keymap.set('n', '(pending-blocked)', function() require('pending').toggle_status('blocked') end) +vim.keymap.set('n', '(pending-cancelled)', function() + require('pending').toggle_status('cancelled') +end) + vim.keymap.set('n', '(pending-priority-up)', function() require('pending').increment_priority() end) From c04057dd9f803764f8da679a235f84fd964636fc Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:01:00 -0400 Subject: [PATCH 20/26] fix(config): use `/` as default cancelled icon (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the cancelled icon defaulted to `c`, inconsistent with the `g/` keymap. Other statuses match: `gw` → `[w]`, `gb` → `[b]`. Solution: change `icons.cancelled` default from `c` to `/` so the keymap and state char are consistent. --- doc/pending.txt | 2 +- lua/pending/config.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 83d09b8..541f773 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -982,7 +982,7 @@ Fields: ~ {priority} Priority task character. Default: '!' {wip} Work-in-progress character. Default: 'w' {blocked} Blocked task character. Default: 'b' - {cancelled} Cancelled task character. Default: 'c' + {cancelled} Cancelled task character. Default: '/' {due} Due date prefix. Default: '.' {recur} Recurrence prefix. Default: '~' {category} Category prefix. Default: '#' diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 368cf21..c282dbd 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -195,7 +195,7 @@ local defaults = { priority = '!', wip = 'w', blocked = 'b', - cancelled = 'c', + cancelled = '/', due = '.', recur = '~', category = '#', From 0b0b64fc3d3f02d8e9ca1d72b83999f3f64f8b88 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Mar 2026 21:01:11 -0400 Subject: [PATCH 21/26] ci: format --- lua/pending/init.lua | 10 ++++++++-- lua/pending/views.lua | 12 ++++++------ plugin/pending.lua | 13 +++++++++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 39c0bae..38fdf50 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1192,7 +1192,10 @@ function M.archive(arg) log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) local count = 0 for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then + if + (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') + and task['end'] + then if task['end'] < cutoff then count = count + 1 end @@ -1213,7 +1216,10 @@ function M.archive(arg) function() local kept = {} for _, task in ipairs(tasks) do - if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then + if + (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') + and task['end'] + then if task['end'] < cutoff then goto skip end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 4321e64..7afeeb7 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -275,9 +275,9 @@ function M.category_view(tasks) category = cat, priority = task.priority, overdue = task.status ~= 'done' - and task.status ~= 'cancelled' - and task.due ~= nil - and parse.is_overdue(task.due) + and task.status ~= 'cancelled' + and task.due ~= nil + and parse.is_overdue(task.due) or nil, recur = task.recur, forge_spans = compute_forge_spans(task, prefix_len), @@ -332,9 +332,9 @@ function M.priority_view(tasks) category = task.category, priority = task.priority, overdue = task.status ~= 'done' - and task.status ~= 'cancelled' - and task.due ~= nil - and parse.is_overdue(task.due) + and task.status ~= 'cancelled' + and task.due ~= nil + and parse.is_overdue(task.due) or nil, show_category = true, recur = task.recur, diff --git a/plugin/pending.lua b/plugin/pending.lua index d9420c6..8e2f633 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -245,8 +245,17 @@ end, { for word in after_filter:gmatch('%S+') do used[word] = true end - local candidates = - { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked', 'cancelled' } + local candidates = { + 'clear', + 'overdue', + 'today', + 'priority', + 'done', + 'pending', + 'wip', + 'blocked', + 'cancelled', + } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() From f472ff899041fd50f68159b7cdcba1394a70b4c1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:22:04 -0400 Subject: [PATCH 22/26] feat: add markdown detail buffer for task notes (#162) Problem: tasks only have a one-line description. There is no way to attach extended notes, checklists, or context to a task. Solution: add `ge` keymap to open a `pending://task/` markdown buffer that replaces the task list in the same split. The buffer shows a read-only metadata header (status, priority, category, due, recurrence) rendered via extmarks, a `---` separator, and editable notes below. `:w` saves notes to a new top-level `notes` field on the task stored in the single `tasks.json`. `q` returns to the task list. --- doc/pending.txt | 32 +++++++ lua/pending/buffer.lua | 210 +++++++++++++++++++++++++++++++++++++++++ lua/pending/config.lua | 2 + lua/pending/init.lua | 43 +++++++++ lua/pending/store.lua | 6 ++ lua/pending/views.lua | 1 + plugin/pending.lua | 4 + 7 files changed, 298 insertions(+) diff --git a/doc/pending.txt b/doc/pending.txt index 541f773..7026922 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -348,6 +348,7 @@ Default buffer-local keys: ~ `gw` Toggle work-in-progress status (`wip`) `gb` Toggle blocked status (`blocked`) `g/` Toggle cancelled status (`cancelled`) + `ge` Open markdown detail buffer for task notes (`edit_notes`) `gf` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) `gz` Undo the last `:w` save (`undo`) @@ -487,6 +488,12 @@ old keys to `false`: >lua Decrement the priority level for the task under the cursor, clamped at 0. Default key: ``. + *(pending-edit-notes)* +(pending-edit-notes) + Open the markdown detail buffer for the task under the cursor. + Shows a read-only metadata header and editable notes below a `---` + separator. Press `q` to return to the task list. Default key: `ge`. + *(pending-open-line)* (pending-open-line) Insert a correctly-formatted blank task line below the cursor. @@ -557,6 +564,29 @@ Queue view: ~ *pending-view-queue* virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. +============================================================================== +DETAIL BUFFER *pending-detail-buffer* + +Press `ge` (or `keymaps.edit_notes`) on a task to open a markdown detail +buffer named `pending://task/`. The buffer replaces the task list in +the same split. + +Layout: ~ + + Line 1: `# ` (task description as heading) + Lines 2-3: Read-only metadata (status, priority, category, due, + recurrence) rendered as virtual text overlays + Line 4: `---` separator + Line 5+: Free-form markdown notes (editable) + +The metadata header is not editable — it is rendered via extmarks on +empty buffer lines. To change metadata, return to the task list and use +the normal keymaps or `:Pending edit`. + +Write (`:w`) saves the notes content (everything below the `---` +separator) to the `notes` field in the task store. Press `q` to return +to the task list. + ============================================================================== FILTERS *pending-filters* @@ -789,6 +819,7 @@ loads: >lua wip = 'gw', blocked = 'gb', cancelled = 'g/', + edit_notes = 'ge', }, sync = { gcal = {}, @@ -1651,6 +1682,7 @@ Task fields: ~ {entry} (string) ISO 8601 UTC timestamp of creation. {modified} (string) ISO 8601 UTC timestamp of last modification. {end} (string) ISO 8601 UTC timestamp of completion or deletion. + {notes} (string) Free-form markdown notes (from detail buffer). {order} (integer) Relative ordering within a category. Any field not in the list above is preserved in `_extra` and written back on diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 403205d..f65ebaa 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -584,6 +584,7 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true }) end ---@return string @@ -805,4 +806,213 @@ function M.open() return task_bufnr end +local ns_detail = vim.api.nvim_create_namespace('pending_detail') +local DETAIL_SEPARATOR = '---' + +---@type integer? +local _detail_bufnr = nil +---@type integer? +local _detail_task_id = nil + +---@return integer? +function M.detail_bufnr() + return _detail_bufnr +end + +---@return integer? +function M.detail_task_id() + return _detail_task_id +end + +---@param bufnr integer +---@param task pending.Task +---@return nil +local function apply_detail_extmarks(bufnr, task) + vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1) + local icons = config.get().icons + local parts = {} + local status_label = task.status or 'pending' + local icon_char = icons[status_label] or icons.pending + table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' }) + if task.priority and task.priority > 0 then + table.insert(parts, { ' ', 'Normal' }) + table.insert( + parts, + { 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' } + ) + end + local line2 = {} + if task.category then + table.insert(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' }) + end + if task.due then + if #line2 > 0 then + table.insert(line2, { ' ', 'Normal' }) + end + local due_label = task.due + local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)') + if y then + local t = os.time({ + year = tonumber(y) --[[@as integer]], + month = tonumber(mo) --[[@as integer]], + day = tonumber(d) --[[@as integer]], + }) + due_label = os.date(config.get().date_format, t) --[[@as string]] + end + table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' }) + end + if task.recur then + if #line2 > 0 then + table.insert(line2, { ' ', 'Normal' }) + end + table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' }) + end + if #parts > 0 then + vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, { + virt_text = parts, + virt_text_pos = 'overlay', + }) + end + if #line2 > 0 then + vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, { + virt_text = line2, + virt_text_pos = 'overlay', + }) + end +end + +---@param task_id integer +---@return integer? bufnr +function M.open_detail(task_id) + if not _store then + return nil + end + if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then + if _detail_task_id == task_id then + return _detail_bufnr + end + vim.api.nvim_buf_delete(_detail_bufnr, { force = true }) + _detail_bufnr = nil + _detail_task_id = nil + end + local task = _store:get(task_id) + if not task then + log.warn('task not found: ' .. task_id) + return nil + end + + setup_highlights() + + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(bufnr, 'pending://task/' .. task_id) + vim.bo[bufnr].buftype = 'acwrite' + vim.bo[bufnr].filetype = 'markdown' + vim.bo[bufnr].swapfile = false + + local lines = { + '# ' .. task.description, + '', + '', + DETAIL_SEPARATOR, + } + local notes = task.notes or '' + if notes ~= '' then + for note_line in (notes .. '\n'):gmatch('(.-)\n') do + table.insert(lines, note_line) + end + else + table.insert(lines, '') + end + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modified = false + + apply_detail_extmarks(bufnr, task) + + local winid = task_winid + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_buf(winid, bufnr) + end + + vim.wo[winid].conceallevel = 0 + vim.wo[winid].foldmethod = 'manual' + vim.wo[winid].foldenable = false + + _detail_bufnr = bufnr + _detail_task_id = task_id + + local separator_row = 3 + local cursor_row = separator_row + 2 + local total = vim.api.nvim_buf_line_count(bufnr) + if cursor_row > total then + cursor_row = total + end + pcall(vim.api.nvim_win_set_cursor, winid, { cursor_row, 0 }) + + return bufnr +end + +---@return nil +function M.close_detail() + if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then + vim.api.nvim_buf_delete(_detail_bufnr, { force = true }) + end + _detail_bufnr = nil + _detail_task_id = nil + + if task_winid and vim.api.nvim_win_is_valid(task_winid) then + if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then + vim.api.nvim_win_set_buf(task_winid, task_bufnr) + set_win_options(task_winid) + M.render(task_bufnr) + end + end +end + +---@return nil +function M.save_detail() + if not _detail_bufnr or not _detail_task_id or not _store then + return + end + local task = _store:get(_detail_task_id) + if not task then + log.warn('task was deleted') + M.close_detail() + return + end + + local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false) + + local sep_row = nil + for i, line in ipairs(lines) do + if line == DETAIL_SEPARATOR then + sep_row = i + break + end + end + + local notes_text = '' + if sep_row and sep_row < #lines then + local note_lines = {} + for i = sep_row + 1, #lines do + table.insert(note_lines, lines[i]) + end + notes_text = table.concat(note_lines, '\n') + notes_text = notes_text:gsub('%s+$', '') + end + + if notes_text == '' then + _store:update(_detail_task_id, { notes = vim.NIL }) + else + _store:update(_detail_task_id, { notes = notes_text }) + end + _store:save() + + vim.bo[_detail_bufnr].modified = false + local updated = _store:get(_detail_task_id) + if updated then + apply_detail_extmarks(_detail_bufnr, updated) + end +end + return M diff --git a/lua/pending/config.lua b/lua/pending/config.lua index c282dbd..0015b37 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -82,6 +82,7 @@ ---@field priority_up_visual? string|false ---@field priority_down_visual? string|false ---@field cancelled? string|false +---@field edit_notes? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -163,6 +164,7 @@ local defaults = { wip = 'gw', blocked = 'gb', cancelled = 'g/', + edit_notes = 'ge', priority_up = '', priority_down = '', priority_up_visual = 'g', diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 38fdf50..5c28998 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -401,6 +401,9 @@ function M._setup_buf_mappings(bufnr) open_line_above = function() buffer.open_line(true) end, + edit_notes = function() + M.open_detail() + end, } for name, fn in pairs(actions) do @@ -888,6 +891,46 @@ function M.toggle_status(target_status) end end +---@return nil +function M.open_detail() + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + + local detail_bufnr = buffer.open_detail(id) + if not detail_bufnr then + return + end + + local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true }) + vim.api.nvim_create_autocmd('BufWriteCmd', { + group = group, + buffer = detail_bufnr, + callback = function() + buffer.save_detail() + end, + }) + + local km = require('pending.config').get().keymaps + vim.keymap.set('n', km.close or 'q', function() + vim.api.nvim_del_augroup_by_name('PendingDetail') + buffer.close_detail() + end, { buffer = detail_bufnr }) +end + ---@param direction 'up'|'down' ---@return nil function M.move_task(direction) diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 7c43c0d..0938eda 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -24,6 +24,7 @@ local config = require('pending.config') ---@field entry string ---@field modified string ---@field end? string +---@field notes? string ---@field order integer ---@field _extra? pending.TaskExtra @@ -93,6 +94,7 @@ local known_fields = { entry = true, modified = true, ['end'] = true, + notes = true, order = true, } @@ -124,6 +126,9 @@ local function task_to_table(task) if task['end'] then t['end'] = task['end'] end + if task.notes then + t.notes = task.notes + end if task.order and task.order ~= 0 then t.order = task.order end @@ -150,6 +155,7 @@ local function table_to_task(t) entry = t.entry, modified = t.modified, ['end'] = t['end'], + notes = t.notes, order = t.order or 0, _extra = {}, } diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 7afeeb7..3dbd06f 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -341,6 +341,7 @@ function M.priority_view(tasks) 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), + has_notes = task.notes ~= nil and task.notes ~= '', }) end diff --git a/plugin/pending.lua b/plugin/pending.lua index 8e2f633..9f25dd5 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -407,6 +407,10 @@ vim.keymap.set('n', '(pending-cancelled)', function() require('pending').toggle_status('cancelled') end) +vim.keymap.set('n', '(pending-edit-notes)', function() + require('pending').open_detail() +end) + vim.keymap.set('n', '(pending-priority-up)', function() require('pending').increment_priority() end) From f846155ee586dd4d422a32cb23f15e1080515961 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:06:45 -0400 Subject: [PATCH 23/26] feat(detail): parse and validate editable frontmatter on save (#163) Problem: the detail buffer rendered metadata as read-only virtual text overlays. Users could not edit status, priority, category, due, or recurrence from the detail view. Solution: render frontmatter as real `Key: value` text lines highlighted via extmarks. On `:w`, `parse_detail_frontmatter()` validates every field (status, priority bounds, `resolve_date`, `recur.validate`) and aborts with `log.error()` on any invalid input. Removing a line clears the field; editing the `# title` updates the description. --- lua/pending/buffer.lua | 250 ++++++++++++++++++------- spec/detail_spec.lua | 402 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 585 insertions(+), 67 deletions(-) create mode 100644 spec/detail_spec.lua diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index f65ebaa..c98ebf9 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -824,59 +824,46 @@ function M.detail_task_id() return _detail_task_id end ----@param bufnr integer +local VALID_STATUSES = { + pending = true, + done = true, + wip = true, + blocked = true, + cancelled = true, +} + ---@param task pending.Task ----@return nil -local function apply_detail_extmarks(bufnr, task) - vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1) - local icons = config.get().icons - local parts = {} - local status_label = task.status or 'pending' - local icon_char = icons[status_label] or icons.pending - table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' }) - if task.priority and task.priority > 0 then - table.insert(parts, { ' ', 'Normal' }) - table.insert( - parts, - { 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' } - ) - end - local line2 = {} +---@return string[] +local function build_detail_frontmatter(task) + local lines = {} + table.insert(lines, 'Status: ' .. (task.status or 'pending')) + table.insert(lines, 'Priority: ' .. (task.priority or 0)) if task.category then - table.insert(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' }) + table.insert(lines, 'Category: ' .. task.category) end if task.due then - if #line2 > 0 then - table.insert(line2, { ' ', 'Normal' }) - end - local due_label = task.due - local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)') - if y then - local t = os.time({ - year = tonumber(y) --[[@as integer]], - month = tonumber(mo) --[[@as integer]], - day = tonumber(d) --[[@as integer]], - }) - due_label = os.date(config.get().date_format, t) --[[@as string]] - end - table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' }) + table.insert(lines, 'Due: ' .. task.due) end if task.recur then - if #line2 > 0 then - table.insert(line2, { ' ', 'Normal' }) + local recur_val = task.recur + if task.recur_mode == 'completion' then + recur_val = '!' .. recur_val end - table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' }) + table.insert(lines, 'Recur: ' .. recur_val) end - if #parts > 0 then - vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, { - virt_text = parts, - virt_text_pos = 'overlay', - }) - end - if #line2 > 0 then - vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, { - virt_text = line2, - virt_text_pos = 'overlay', + return lines +end + +---@param bufnr integer +---@param sep_row integer +---@return nil +local function apply_detail_extmarks(bufnr, sep_row) + vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1) + for i = 1, sep_row - 1 do + vim.api.nvim_buf_set_extmark(bufnr, ns_detail, i, 0, { + end_row = i, + end_col = #(vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ''), + hl_group = 'PendingDetailMeta', }) end end @@ -909,12 +896,12 @@ function M.open_detail(task_id) vim.bo[bufnr].filetype = 'markdown' vim.bo[bufnr].swapfile = false - local lines = { - '# ' .. task.description, - '', - '', - DETAIL_SEPARATOR, - } + local lines = { '# ' .. task.description } + local fm = build_detail_frontmatter(task) + for _, fl in ipairs(fm) do + table.insert(lines, fl) + end + table.insert(lines, DETAIL_SEPARATOR) local notes = task.notes or '' if notes ~= '' then for note_line in (notes .. '\n'):gmatch('(.-)\n') do @@ -927,7 +914,8 @@ function M.open_detail(task_id) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modified = false - apply_detail_extmarks(bufnr, task) + local sep_row = #fm + 1 + apply_detail_extmarks(bufnr, sep_row) local winid = task_winid if winid and vim.api.nvim_win_is_valid(winid) then @@ -941,8 +929,7 @@ function M.open_detail(task_id) _detail_bufnr = bufnr _detail_task_id = task_id - local separator_row = 3 - local cursor_row = separator_row + 2 + local cursor_row = sep_row + 2 local total = vim.api.nvim_buf_line_count(bufnr) if cursor_row > total then cursor_row = total @@ -969,6 +956,124 @@ function M.close_detail() end end +---@param lines string[] +---@return integer? sep_row +---@return pending.DetailFields? fields +---@return string? err +local function parse_detail_frontmatter(lines) + local parse = require('pending.parse') + local recur = require('pending.recur') + local cfg = config.get() + + local sep_row = nil + for i, line in ipairs(lines) do + if line == DETAIL_SEPARATOR then + sep_row = i + break + end + end + if not sep_row then + return nil, nil, 'missing separator (---)' + end + + local desc = lines[1] and lines[1]:match('^# (.+)$') + if not desc or desc:match('^%s*$') then + return nil, nil, 'missing or empty title (first line must be # )' + end + + ---@class pending.DetailFields + ---@field description string + ---@field status pending.TaskStatus + ---@field priority integer + ---@field category? string|userdata + ---@field due? string|userdata + ---@field recur? string|userdata + ---@field recur_mode? pending.RecurMode|userdata + local fields = { + description = desc, + status = 'pending', + priority = 0, + category = vim.NIL, + due = vim.NIL, + recur = vim.NIL, + recur_mode = vim.NIL, + } + + local seen = {} ---@type table<string, boolean> + for i = 2, sep_row - 1 do + local line = lines[i] + if line:match('^%s*$') then + goto continue + end + local key, val = line:match('^(%S+):%s*(.*)$') + if not key then + return nil, nil, 'invalid frontmatter line: ' .. line + end + key = key:lower() + if seen[key] then + return nil, nil, 'duplicate field: ' .. key + end + seen[key] = true + + if key == 'status' then + val = val:lower() + if not VALID_STATUSES[val] then + return nil, nil, 'invalid status: ' .. val + end + fields.status = val --[[@as pending.TaskStatus]] + elseif key == 'priority' then + local n = tonumber(val) + if not n or n ~= math.floor(n) or n < 0 then + return nil, nil, 'invalid priority: ' .. val .. ' (must be integer >= 0)' + end + local max = cfg.max_priority or 3 + if n > max then + return nil, nil, 'invalid priority: ' .. val .. ' (max is ' .. max .. ')' + end + fields.priority = n --[[@as integer]] + elseif key == 'category' then + if val == '' then + return nil, nil, 'empty category value' + end + fields.category = val + elseif key == 'due' then + if val == '' then + return nil, nil, 'empty due value (remove the line to clear)' + end + local resolved = parse.resolve_date(val) + if resolved then + fields.due = resolved + elseif + val:match('^%d%d%d%d%-%d%d%-%d%d$') or val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$') + then + fields.due = val + else + return nil, nil, 'invalid due date: ' .. val + end + elseif key == 'recur' then + if val == '' then + return nil, nil, 'empty recur value (remove the line to clear)' + end + local raw_spec = val + local rec_mode = nil + if raw_spec:sub(1, 1) == '!' then + rec_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + if not recur.validate(raw_spec) then + return nil, nil, 'invalid recurrence: ' .. val + end + fields.recur = raw_spec + fields.recur_mode = rec_mode or vim.NIL + else + return nil, nil, 'unknown field: ' .. key + end + ::continue:: + end + + return sep_row, fields, nil +end + ---@return nil function M.save_detail() if not _detail_bufnr or not _detail_task_id or not _store then @@ -983,16 +1088,16 @@ function M.save_detail() local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false) - local sep_row = nil - for i, line in ipairs(lines) do - if line == DETAIL_SEPARATOR then - sep_row = i - break - end + local sep_row, fields, err = parse_detail_frontmatter(lines) + if err then + log.error(err) + return end + ---@cast sep_row integer + ---@cast fields pending.DetailFields local notes_text = '' - if sep_row and sep_row < #lines then + if sep_row < #lines then local note_lines = {} for i = sep_row + 1, #lines do table.insert(note_lines, lines[i]) @@ -1001,18 +1106,29 @@ function M.save_detail() notes_text = notes_text:gsub('%s+$', '') end + local update = { + description = fields.description, + status = fields.status, + priority = fields.priority, + category = fields.category, + due = fields.due, + recur = fields.recur, + recur_mode = fields.recur_mode, + } if notes_text == '' then - _store:update(_detail_task_id, { notes = vim.NIL }) + update.notes = vim.NIL else - _store:update(_detail_task_id, { notes = notes_text }) + update.notes = notes_text end + + _store:update(_detail_task_id, update) _store:save() vim.bo[_detail_bufnr].modified = false - local updated = _store:get(_detail_task_id) - if updated then - apply_detail_extmarks(_detail_bufnr, updated) - end + apply_detail_extmarks(_detail_bufnr, sep_row - 1) end +M._parse_detail_frontmatter = parse_detail_frontmatter +M._build_detail_frontmatter = build_detail_frontmatter + return M diff --git a/spec/detail_spec.lua b/spec/detail_spec.lua new file mode 100644 index 0000000..50f7ae7 --- /dev/null +++ b/spec/detail_spec.lua @@ -0,0 +1,402 @@ +require('spec.helpers') + +local config = require('pending.config') + +describe('detail frontmatter', function() + local buffer + local tmpdir + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.buffer'] = nil + buffer = require('pending.buffer') + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + package.loaded['pending'] = nil + package.loaded['pending.buffer'] = nil + end) + + describe('build_detail_frontmatter', function() + it('renders status and priority for minimal task', function() + local lines = buffer._build_detail_frontmatter({ + id = 1, + description = 'Test', + status = 'pending', + priority = 0, + entry = '', + modified = '', + order = 0, + }) + assert.are.equal(2, #lines) + assert.are.equal('Status: pending', lines[1]) + assert.are.equal('Priority: 0', lines[2]) + end) + + it('renders all fields', function() + local lines = buffer._build_detail_frontmatter({ + id = 1, + description = 'Test', + status = 'wip', + priority = 2, + category = 'Work', + due = '2026-03-15', + recur = 'weekly', + entry = '', + modified = '', + order = 0, + }) + assert.are.equal(5, #lines) + assert.are.equal('Status: wip', lines[1]) + assert.are.equal('Priority: 2', lines[2]) + assert.are.equal('Category: Work', lines[3]) + assert.are.equal('Due: 2026-03-15', lines[4]) + assert.are.equal('Recur: weekly', lines[5]) + end) + + it('prefixes recur with ! for completion mode', function() + local lines = buffer._build_detail_frontmatter({ + id = 1, + description = 'Test', + status = 'pending', + priority = 0, + recur = 'daily', + recur_mode = 'completion', + entry = '', + modified = '', + order = 0, + }) + assert.are.equal('Recur: !daily', lines[3]) + end) + + it('omits optional fields when absent', function() + local lines = buffer._build_detail_frontmatter({ + id = 1, + description = 'Test', + status = 'done', + priority = 1, + entry = '', + modified = '', + order = 0, + }) + assert.are.equal(2, #lines) + assert.are.equal('Status: done', lines[1]) + assert.are.equal('Priority: 1', lines[2]) + end) + end) + + describe('parse_detail_frontmatter', function() + it('parses minimal frontmatter', function() + local lines = { + '# My task', + 'Status: pending', + 'Priority: 0', + '---', + 'some notes', + } + local sep, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal(4, sep) + assert.are.equal('My task', fields.description) + assert.are.equal('pending', fields.status) + assert.are.equal(0, fields.priority) + end) + + it('parses all fields', function() + local lines = { + '# Fix the bug', + 'Status: wip', + 'Priority: 2', + 'Category: Work', + 'Due: 2026-03-15', + 'Recur: weekly', + '---', + } + local sep, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal(7, sep) + assert.are.equal('Fix the bug', fields.description) + assert.are.equal('wip', fields.status) + assert.are.equal(2, fields.priority) + assert.are.equal('Work', fields.category) + assert.are.equal('2026-03-15', fields.due) + assert.are.equal('weekly', fields.recur) + end) + + it('resolves due date keywords', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 0', + 'Due: tomorrow', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + local today = os.date('*t') --[[@as osdate]] + local expected = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 1 }) + ) + assert.are.equal(expected, fields.due) + end) + + it('parses completion-mode recurrence', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 0', + 'Recur: !daily', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('daily', fields.recur) + assert.are.equal('completion', fields.recur_mode) + end) + + it('clears optional fields when lines removed', function() + local lines = { + '# Task', + 'Status: done', + 'Priority: 1', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal(vim.NIL, fields.category) + assert.are.equal(vim.NIL, fields.due) + assert.are.equal(vim.NIL, fields.recur) + end) + + it('skips blank lines in frontmatter', function() + local lines = { + '# Task', + 'Status: pending', + '', + 'Priority: 0', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('pending', fields.status) + assert.are.equal(0, fields.priority) + end) + + it('errors on missing separator', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 0', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('missing separator')) + end) + + it('errors on missing title', function() + local lines = { + '', + 'Status: pending', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('missing or empty title')) + end) + + it('errors on empty title', function() + local lines = { + '# ', + 'Status: pending', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('missing or empty title')) + end) + + it('errors on invalid status', function() + local lines = { + '# Task', + 'Status: bogus', + 'Priority: 0', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid status')) + end) + + it('errors on negative priority', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: -1', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid priority')) + end) + + it('errors on non-integer priority', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 1.5', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid priority')) + end) + + it('errors on priority exceeding max', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 4', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('max is 3')) + end) + + it('errors on invalid due date', function() + local lines = { + '# Task', + 'Status: pending', + 'Priority: 0', + 'Due: notadate', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid due date')) + end) + + it('errors on empty due value', function() + local lines = { + '# Task', + 'Due: ', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('empty due value')) + end) + + it('errors on invalid recurrence', function() + local lines = { + '# Task', + 'Recur: nope', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid recurrence')) + end) + + it('errors on empty recur value', function() + local lines = { + '# Task', + 'Recur: ', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('empty recur value')) + end) + + it('errors on empty category value', function() + local lines = { + '# Task', + 'Category: ', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('empty category')) + end) + + it('errors on unknown field', function() + local lines = { + '# Task', + 'Status: pending', + 'Foo: bar', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('unknown field: foo')) + end) + + it('errors on duplicate field', function() + local lines = { + '# Task', + 'Status: pending', + 'Status: done', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('duplicate field')) + end) + + it('errors on malformed frontmatter line', function() + local lines = { + '# Task', + 'not a key value pair', + '---', + } + local _, _, err = buffer._parse_detail_frontmatter(lines) + assert.truthy(err:find('invalid frontmatter line')) + end) + + it('is case-insensitive for field keys', function() + local lines = { + '# Task', + 'STATUS: wip', + 'PRIORITY: 1', + 'CATEGORY: Work', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('wip', fields.status) + assert.are.equal(1, fields.priority) + assert.are.equal('Work', fields.category) + end) + + it('accepts datetime due format', function() + local lines = { + '# Task', + 'Due: 2026-03-15T14:00', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('2026-03-15T14:00', fields.due) + end) + + it('respects custom max_priority', function() + vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 } + config.reset() + local lines = { + '# Task', + 'Priority: 5', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal(5, fields.priority) + end) + + it('updates description from title line', function() + local lines = { + '# Updated title', + 'Status: pending', + 'Priority: 0', + '---', + } + local _, fields, err = buffer._parse_detail_frontmatter(lines) + assert.is_nil(err) + assert.are.equal('Updated title', fields.description) + end) + end) +end) From 2b75843dabc16c5ef3d5e54a6948a612fffdf051 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <br.barrettruth@gmail.com> Date: Fri, 13 Mar 2026 17:58:49 -0400 Subject: [PATCH 24/26] fix: revert dev --- lua/pending/health.lua | 8 +++- lua/pending/init.lua | 58 +++++++++++++++++++++++--- plugin/pending.lua | 12 +++--- spec/sync_spec.lua | 94 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 12 deletions(-) diff --git a/lua/pending/health.lua b/lua/pending/health.lua index d00031b..7d95b5d 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -59,7 +59,7 @@ function M.check() end local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) - if #sync_paths == 0 then + if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then vim.health.info('No sync backends found') else for _, path in ipairs(sync_paths) do @@ -70,6 +70,12 @@ function M.check() backend.health() end end + for rname, rbackend in pairs(require('pending').registered_backends()) do + if type(rbackend.health) == 'function' then + vim.health.start('pending.nvim: sync/' .. rname) + rbackend.health() + end + end end end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 5c28998..013533c 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1141,18 +1141,60 @@ end ---@class pending.SyncBackend ---@field name string ----@field auth fun(): nil +---@field auth? fun(sub_action?: string): nil ---@field push? fun(): nil ---@field pull? fun(): nil ---@field sync? fun(): nil ---@field health? fun(): nil +---@type table<string, pending.SyncBackend> +local _registered_backends = {} + ---@type string[]? local _sync_backends = nil ---@type table<string, true>? local _sync_backend_set = nil +---@param name string +---@return pending.SyncBackend? +function M.resolve_backend(name) + if _registered_backends[name] then + return _registered_backends[name] + end + local ok, mod = pcall(require, 'pending.sync.' .. name) + if ok and type(mod) == 'table' and mod.name then + return mod + end + return nil +end + +---@param backend pending.SyncBackend +---@return nil +function M.register_backend(backend) + if type(backend) ~= 'table' or type(backend.name) ~= 'string' or backend.name == '' then + log.error('register_backend: backend must have a non-empty `name` field') + return + end + local builtin_ok, builtin = pcall(require, 'pending.sync.' .. backend.name) + if builtin_ok and type(builtin) == 'table' and builtin.name then + log.error('register_backend: backend `' .. backend.name .. '` already exists as a built-in') + return + end + if _registered_backends[backend.name] then + log.error('register_backend: backend `' .. backend.name .. '` is already registered') + return + end + _registered_backends[backend.name] = backend + _sync_backends = nil + _sync_backend_set = nil +end + +---@return table<string, pending.SyncBackend> +function M.registered_backends() + return _registered_backends +end + ---@return string[], table<string, true> local function discover_backends() if _sync_backends then @@ -1169,6 +1211,12 @@ local function discover_backends() _sync_backend_set[mod.name] = true end end + for name, _ in pairs(_registered_backends) do + if not _sync_backend_set[name] then + table.insert(_sync_backends, name) + _sync_backend_set[name] = true + end + end table.sort(_sync_backends) return _sync_backends, _sync_backend_set end @@ -1177,8 +1225,8 @@ end ---@param action? string ---@return nil local function run_sync(backend_name, action) - local ok, backend = pcall(require, 'pending.sync.' .. backend_name) - if not ok then + local backend = M.resolve_backend(backend_name) + if not backend then log.error('Unknown sync backend: ' .. backend_name) return end @@ -1543,8 +1591,8 @@ function M.auth(args) local backends_list = discover_backends() local auth_backends = {} for _, name in ipairs(backends_list) do - local ok, mod = pcall(require, 'pending.sync.' .. name) - if ok and type(mod.auth) == 'function' then + local mod = M.resolve_backend(name) + if mod and type(mod.auth) == 'function' then table.insert(auth_backends, { name = name, mod = mod }) end end diff --git a/plugin/pending.lua b/plugin/pending.lua index 9f25dd5..48ade42 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -304,8 +304,8 @@ end, { if #parts == 0 or (#parts == 1 and not trailing) then local auth_names = {} for _, b in ipairs(pending.sync_backends()) do - local ok, mod = pcall(require, 'pending.sync.' .. b) - if ok and type(mod.auth) == 'function' then + local mod = pending.resolve_backend(b) + if mod and type(mod.auth) == 'function' then table.insert(auth_names, b) end end @@ -313,8 +313,8 @@ end, { end local backend_name = parts[1] if #parts == 1 or (#parts == 2 and not trailing) then - local ok, mod = pcall(require, 'pending.sync.' .. backend_name) - if ok and type(mod.auth_complete) == 'function' then + local mod = pending.resolve_backend(backend_name) + if mod and type(mod.auth_complete) == 'function' then return filter_candidates(arg_lead, mod.auth_complete()) end return {} @@ -328,8 +328,8 @@ end, { if not after_backend then return {} end - local ok, mod = pcall(require, 'pending.sync.' .. matched_backend) - if not ok then + local mod = pending.resolve_backend(matched_backend) + if not mod then return {} end local actions = {} diff --git a/spec/sync_spec.lua b/spec/sync_spec.lua index 51156bf..b7dfe8d 100644 --- a/spec/sync_spec.lua +++ b/spec/sync_spec.lua @@ -124,6 +124,100 @@ describe('sync', function() end) end) + describe('register_backend', function() + it('registers a custom backend', function() + pending.register_backend({ name = 'custom', pull = function() end }) + local set = pending.sync_backend_set() + assert.is_true(set['custom'] == true) + assert.is_true(vim.tbl_contains(pending.sync_backends(), 'custom')) + end) + + it('rejects backend without name', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.register_backend({}) + vim.notify = orig + assert.truthy(msg and msg:find('non%-empty')) + end) + + it('rejects backend with empty name', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.register_backend({ name = '' }) + vim.notify = orig + assert.truthy(msg and msg:find('non%-empty')) + end) + + it('rejects duplicate of built-in backend', function() + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.register_backend({ name = 'gcal' }) + vim.notify = orig + assert.truthy(msg and msg:find('already exists')) + end) + + it('rejects duplicate registered backend', function() + pending.register_backend({ name = 'dup_test', pull = function() end }) + local msg + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.ERROR then + msg = m + end + end + pending.register_backend({ name = 'dup_test' }) + vim.notify = orig + assert.truthy(msg and msg:find('already registered')) + end) + end) + + describe('resolve_backend', function() + it('resolves built-in backend', function() + local mod = pending.resolve_backend('gcal') + assert.is_not_nil(mod) + assert.are.equal('gcal', mod.name) + end) + + it('resolves registered backend', function() + local custom = { name = 'resolve_test', pull = function() end } + pending.register_backend(custom) + local mod = pending.resolve_backend('resolve_test') + assert.is_not_nil(mod) + assert.are.equal('resolve_test', mod.name) + end) + + it('returns nil for unknown backend', function() + assert.is_nil(pending.resolve_backend('nonexistent_xyz')) + end) + + it('dispatches command to registered backend', function() + local called = false + pending.register_backend({ + name = 'cmd_test', + pull = function() + called = true + end, + }) + pending.command('cmd_test pull') + assert.is_true(called) + end) + end) + describe('auto-discovery', function() it('discovers gcal and gtasks backends', function() local backends = pending.sync_backends() From e816e6fb7e145e12fa22942fecf52db48d8ba176 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <br.barrettruth@gmail.com> Date: Fri, 13 Mar 2026 20:38:29 -0400 Subject: [PATCH 25/26] ci: some fixes --- doc/pending.txt | 15 +++-- lua/pending/parse.lua | 129 +++++++++++++++++++++--------------------- spec/parse_spec.lua | 44 +++++++++++++- 3 files changed, 111 insertions(+), 77 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 7026922..3052d15 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -140,9 +140,9 @@ COMMANDS *pending-commands* :Pending add Work: standup due:tomorrow rec:weekdays :Pending add Buy milk due:fri +!! < - Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped - at `max_priority`). If the buffer is currently open it is re-rendered - after the add. + `+!`, `+!!`, or `+!!!` tokens anywhere in the text set the priority + level (capped at `max_priority`). If the buffer is currently open it + is re-rendered after the add. *:Pending-archive* :Pending archive [{duration}] @@ -638,8 +638,8 @@ task data. ============================================================================== INLINE METADATA *pending-metadata* -Metadata tokens may be appended to any task line before saving. Tokens are -parsed from the right and consumed until a non-metadata token is reached. +Metadata tokens may appear anywhere in a task line. On save, tokens are +extracted from any position and the remaining words form the description. Supported tokens: ~ @@ -663,9 +663,8 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as `2026-03-15` and rendered as right-aligned virtual text, and the task is placed under the `Errands` category header. -Parsing stops at the first token that is not a recognised metadata token. -Repeated tokens of the same type also stop parsing — only one `due:`, one -`cat:`, and one `rec:` per task line are consumed. +Only the first occurrence of each metadata type is consumed — duplicate +tokens are silently dropped. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. In insert mode, type the token prefix and press `<C-x><C-o>` to see diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 1b36578..9fd179e 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -536,7 +536,6 @@ function M.body(text) end local metadata = {} - local i = #tokens local ck = category_key() local dk = date_key() local rk = recur_key() @@ -544,84 +543,82 @@ function M.body(text) local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' - local forge_indices = {} + local desc_tokens = {} + local forge_tokens = {} + + for _, token in ipairs(tokens) do + local consumed = false - while i >= 1 do - local token = tokens[i] local due_val = token:match(date_pattern_strict) - if due_val then - if metadata.due then - break + if due_val and is_valid_datetime(due_val) then + if not metadata.due then + metadata.due = due_val end - if not is_valid_datetime(due_val) then - break - end - metadata.due = due_val - i = i - 1 - else + consumed = true + end + if not consumed then local raw_val = token:match(date_pattern_any) if raw_val then - if metadata.due then - break - end local resolved = M.resolve_date(raw_val) - if not resolved then - break - end - metadata.due = resolved - i = i - 1 - else - local cat_val = token:match(cat_pattern) - if cat_val then - if metadata.category then - break + if resolved then + if not metadata.due then + metadata.due = resolved end + consumed = true + end + end + end + + if not consumed then + local cat_val = token:match(cat_pattern) + if cat_val then + if not metadata.category then metadata.category = cat_val - i = i - 1 - else - local pri_bangs = token:match('^%+(!+)$') - if pri_bangs then - if metadata.priority then - break - end - local max = config.get().max_priority or 3 - metadata.priority = math.min(#pri_bangs, max) - i = i - 1 - else - local rec_val = token:match(rec_pattern) - if rec_val then - if metadata.recur then - break - end - local recur = require('pending.recur') - local raw_spec = rec_val - if raw_spec:sub(1, 1) == '!' then - metadata.recur_mode = 'completion' - raw_spec = raw_spec:sub(2) - end - if not recur.validate(raw_spec) then - break - end - metadata.recur = raw_spec - i = i - 1 - elseif forge.parse_ref(token) then - table.insert(forge_indices, i) - i = i - 1 - else - break - end - end end + consumed = true + end + end + + if not consumed then + local pri_bangs = token:match('^%+(!+)$') + if pri_bangs then + if not metadata.priority then + local max = config.get().max_priority or 3 + metadata.priority = math.min(#pri_bangs, max) + end + consumed = true + end + end + + if not consumed then + local rec_val = token:match(rec_pattern) + if rec_val then + local recur = require('pending.recur') + local raw_spec = rec_val + if raw_spec:sub(1, 1) == '!' then + raw_spec = raw_spec:sub(2) + end + if recur.validate(raw_spec) then + if not metadata.recur then + metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil + metadata.recur = raw_spec + end + consumed = true + end + end + end + + if not consumed then + if forge.parse_ref(token) then + table.insert(forge_tokens, token) + else + table.insert(desc_tokens, token) end end end - local desc_tokens = {} - for j = 1, i do - table.insert(desc_tokens, tokens[j]) - end - for fi = #forge_indices, 1, -1 do - table.insert(desc_tokens, tokens[forge_indices[fi]]) + for _, ft in ipairs(forge_tokens) do + table.insert(desc_tokens, ft) end local description = table.concat(desc_tokens, ' ') diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index aebe0c7..e02f1dc 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -48,10 +48,16 @@ describe('parse', function() assert.are.equal('Errands', meta.category) end) - it('stops at duplicate key', function() + it('first occurrence wins for duplicate keys', function() local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') - assert.are.equal('Buy milk due:2026-03-15', desc) - assert.are.equal('2026-04-01', meta.due) + assert.are.equal('Buy milk', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('drops identical duplicate metadata tokens', function() + local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow') + assert.are.equal('Buy milk', desc) + assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) end) it('stops at non-meta token', function() @@ -138,6 +144,38 @@ describe('parse', function() assert.are.equal('Work', meta.category) assert.truthy(desc:find('gl:a/b#12', 1, true)) end) + + it('extracts leading metadata', function() + local desc, meta = parse.body('due:2026-03-15 Fix the bug') + assert.are.equal('Fix the bug', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('extracts metadata from the middle', function() + local desc, meta = parse.body('Fix due:2026-03-15 the bug') + assert.are.equal('Fix the bug', desc) + assert.are.equal('2026-03-15', meta.due) + end) + + it('extracts multiple metadata from any position', function() + local desc, meta = parse.body('cat:Work Fix due:2026-03-15 the bug') + assert.are.equal('Fix the bug', desc) + assert.are.equal('2026-03-15', meta.due) + assert.are.equal('Work', meta.category) + end) + + it('extracts all metadata types from mixed positions', function() + local today = os.date('*t') --[[@as osdate]] + local tomorrow = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 1 }) + ) + local desc, meta = parse.body('due:tomorrow cat:Work Fix the bug +!') + assert.are.equal('Fix the bug', desc) + assert.are.equal(tomorrow, meta.due) + assert.are.equal('Work', meta.category) + assert.are.equal(1, meta.priority) + end) end) describe('parse.resolve_date', function() From 49038f930864a1a2d868dab9a5d7347261d8174a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:48:18 -0400 Subject: [PATCH 26/26] fix(parse): position-independent inline metadata parsing (#164) Problem: `parse.body()` scanned tokens right-to-left and broke on the first non-metadata token, so metadata only worked at the trailing end of a line. `due:tomorrow Fix the bug` silently failed to parse the due date. Solution: Replace the right-to-left `while` loop with a single left-to-right pass that extracts metadata tokens from any position. Duplicate metadata tokens are dropped with a `log.warn`. Update docs and tests accordingly. --- doc/pending.txt | 2 +- lua/pending/parse.lua | 11 +++++++++++ spec/parse_spec.lua | 23 +++++++++++++++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 3052d15..9a62c3d 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -664,7 +664,7 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as placed under the `Errands` category header. Only the first occurrence of each metadata type is consumed — duplicate -tokens are silently dropped. +tokens are dropped with a warning. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. In insert mode, type the token prefix and press `<C-x><C-o>` to see diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 9fd179e..a85d7af 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -1,5 +1,6 @@ local config = require('pending.config') local forge = require('pending.forge') +local log = require('pending.log') ---@class pending.Metadata ---@field due? string @@ -553,6 +554,8 @@ function M.body(text) if due_val and is_valid_datetime(due_val) then if not metadata.due then metadata.due = due_val + else + log.warn('duplicate ' .. dk .. ': token ignored: ' .. token) end consumed = true end @@ -563,6 +566,8 @@ function M.body(text) if resolved then if not metadata.due then metadata.due = resolved + else + log.warn('duplicate ' .. dk .. ': token ignored: ' .. token) end consumed = true end @@ -574,6 +579,8 @@ function M.body(text) if cat_val then if not metadata.category then metadata.category = cat_val + else + log.warn('duplicate ' .. ck .. ': token ignored: ' .. token) end consumed = true end @@ -585,6 +592,8 @@ function M.body(text) if not metadata.priority then local max = config.get().max_priority or 3 metadata.priority = math.min(#pri_bangs, max) + else + log.warn('duplicate priority token ignored: ' .. token) end consumed = true end @@ -602,6 +611,8 @@ function M.body(text) if not metadata.recur then metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil metadata.recur = raw_spec + else + log.warn('duplicate ' .. rk .. ': token ignored: ' .. token) end consumed = true end diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index e02f1dc..b0a3f8e 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -48,16 +48,35 @@ describe('parse', function() assert.are.equal('Errands', meta.category) end) - it('first occurrence wins for duplicate keys', function() + it('first occurrence wins for duplicate keys and warns', function() + local warnings = {} + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.WARN then + table.insert(warnings, m) + end + end local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') + vim.notify = orig assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) + assert.are.equal(1, #warnings) + assert.truthy(warnings[1]:find('duplicate', 1, true)) end) - it('drops identical duplicate metadata tokens', function() + it('drops identical duplicate metadata tokens and warns', function() + local warnings = {} + local orig = vim.notify + vim.notify = function(m, level) + if level == vim.log.levels.WARN then + table.insert(warnings, m) + end + end local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow') + vim.notify = orig assert.are.equal('Buy milk', desc) assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due) + assert.are.equal(1, #warnings) end) it('stops at non-meta token', function()