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).
This commit is contained in:
parent
e9f21c0f0b
commit
2fd95e6dde
9 changed files with 109 additions and 45 deletions
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 = '<C-a>',
|
||||
priority_down = '<C-x>',
|
||||
priority_up_visual = 'g<C-a>',
|
||||
|
|
@ -190,8 +193,9 @@ local defaults = {
|
|||
pending = ' ',
|
||||
done = 'x',
|
||||
priority = '!',
|
||||
wip = '>',
|
||||
blocked = '=',
|
||||
wip = 'w',
|
||||
blocked = 'b',
|
||||
cancelled = 'c',
|
||||
due = '.',
|
||||
recur = '~',
|
||||
category = '#',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -71,21 +71,24 @@ local function compute_forge_spans(task, prefix_len)
|
|||
end
|
||||
|
||||
---@type table<string, integer>
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue