From 2fd95e6dde99bcd0dcdca7a1968008d7e1c9537a 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] 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)