feat: complete task editing coverage (#109)
Problem: the task editing surface had gaps — category and recurrence had no keymaps, `:Pending edit` required knowing the task ID, tasks couldn't be reordered with a keymap, priority was binary (0/1), and `wip`/`blocked` states were documented but unimplemented. Solution: fill every cell so every property is editable in every way. - `gc`/`gr` keymaps for category select and recurrence prompt - cursor-aware `:Pending edit` (omit ID to use task under cursor) - `J`/`K` keymaps to reorder tasks within a category - multi-level priorities (`max_priority` config, `g!` cycles 0→1→2→3→0) - `+!!`/`+!!!` tokens in `:Pending edit`, `:Pending add`, `parse.body()` - `PendingPriority2`/`PendingPriority3` highlight groups - `gw`/`gb` keymaps toggle `wip`/`blocked` status - `>`/`=` state chars in buffer rendering and diff parsing - `PendingWip`/`PendingBlocked` highlight groups - sort order: wip → pending → blocked → done - `wip`/`blocked` filter predicates and icons
This commit is contained in:
parent
fda8c1208c
commit
24bc1e395b
9 changed files with 498 additions and 101 deletions
|
|
@ -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 == 'pending' then
|
||||
if task.status ~= 'done' and task.status ~= 'deleted' then
|
||||
pending = pending + 1
|
||||
if task.priority > 0 then
|
||||
priority = priority + 1
|
||||
|
|
@ -163,6 +163,16 @@ local function compute_hidden_ids(tasks, predicates)
|
|||
visible = false
|
||||
break
|
||||
end
|
||||
elseif pred == 'wip' then
|
||||
if task.status ~= 'wip' then
|
||||
visible = false
|
||||
break
|
||||
end
|
||||
elseif pred == 'blocked' then
|
||||
if task.status ~= 'blocked' then
|
||||
visible = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not visible then
|
||||
|
|
@ -335,6 +345,24 @@ function M._setup_buf_mappings(bufnr)
|
|||
date = function()
|
||||
M.prompt_date()
|
||||
end,
|
||||
category = function()
|
||||
M.prompt_category()
|
||||
end,
|
||||
recur = function()
|
||||
M.prompt_recur()
|
||||
end,
|
||||
move_down = function()
|
||||
M.move_task('down')
|
||||
end,
|
||||
move_up = function()
|
||||
M.move_task('up')
|
||||
end,
|
||||
wip = function()
|
||||
M.toggle_status('wip')
|
||||
end,
|
||||
blocked = function()
|
||||
M.toggle_status('blocked')
|
||||
end,
|
||||
undo = function()
|
||||
M.undo_write()
|
||||
end,
|
||||
|
|
@ -605,7 +633,8 @@ function M.toggle_priority()
|
|||
if not task then
|
||||
return
|
||||
end
|
||||
local new_priority = task.priority > 0 and 0 or 1
|
||||
local max = require('pending.config').get().max_priority or 3
|
||||
local new_priority = (task.priority + 1) % (max + 1)
|
||||
s:update(id, { priority = new_priority })
|
||||
_save_and_notify()
|
||||
buffer.render(bufnr)
|
||||
|
|
@ -658,6 +687,222 @@ function M.prompt_date()
|
|||
end)
|
||||
end
|
||||
|
||||
---@param target_status 'wip'|'blocked'
|
||||
---@return nil
|
||||
function M.toggle_status(target_status)
|
||||
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 s = get_store()
|
||||
local task = s:get(id)
|
||||
if not task then
|
||||
return
|
||||
end
|
||||
if task.status == target_status then
|
||||
s:update(id, { status = 'pending' })
|
||||
else
|
||||
s:update(id, { status = target_status })
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param direction 'up'|'down'
|
||||
---@return nil
|
||||
function M.move_task(direction)
|
||||
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 target_row
|
||||
if direction == 'down' then
|
||||
target_row = row + 1
|
||||
else
|
||||
target_row = row - 1
|
||||
end
|
||||
if not meta[target_row] or meta[target_row].type ~= 'task' then
|
||||
return
|
||||
end
|
||||
|
||||
local current_view_name = buffer.current_view_name() or 'category'
|
||||
if current_view_name == 'category' then
|
||||
if meta[target_row].category ~= meta[row].category then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local target_id = meta[target_row].id
|
||||
if not target_id then
|
||||
return
|
||||
end
|
||||
|
||||
local s = get_store()
|
||||
local task_a = s:get(id)
|
||||
local task_b = s:get(target_id)
|
||||
if not task_a or not task_b then
|
||||
return
|
||||
end
|
||||
|
||||
if task_a.order == 0 or task_b.order == 0 then
|
||||
local tasks
|
||||
if current_view_name == 'category' then
|
||||
tasks = {}
|
||||
for _, t in ipairs(s:active_tasks()) do
|
||||
if t.category == task_a.category then
|
||||
table.insert(tasks, t)
|
||||
end
|
||||
end
|
||||
else
|
||||
tasks = s:active_tasks()
|
||||
end
|
||||
table.sort(tasks, function(a, b)
|
||||
if a.order ~= b.order then
|
||||
return a.order < b.order
|
||||
end
|
||||
return a.id < b.id
|
||||
end)
|
||||
for i, t in ipairs(tasks) do
|
||||
s:update(t.id, { order = i })
|
||||
end
|
||||
task_a = s:get(id)
|
||||
task_b = s:get(target_id)
|
||||
if not task_a or not task_b then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local order_a, order_b = task_a.order, task_b.order
|
||||
s:update(id, { order = order_b })
|
||||
s:update(target_id, { order = order_a })
|
||||
_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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.prompt_category()
|
||||
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 s = get_store()
|
||||
local seen = {}
|
||||
local categories = {}
|
||||
for _, task in ipairs(s:active_tasks()) do
|
||||
if task.category and not seen[task.category] then
|
||||
seen[task.category] = true
|
||||
table.insert(categories, task.category)
|
||||
end
|
||||
end
|
||||
table.sort(categories)
|
||||
vim.ui.select(categories, { prompt = 'Category: ' }, function(choice)
|
||||
if not choice then
|
||||
return
|
||||
end
|
||||
s:update(id, { category = choice })
|
||||
_save_and_notify()
|
||||
buffer.render(bufnr)
|
||||
end)
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.prompt_recur()
|
||||
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
|
||||
vim.ui.input({ prompt = 'Recurrence (e.g. weekly, !daily): ' }, function(input)
|
||||
if not input then
|
||||
return
|
||||
end
|
||||
local s = get_store()
|
||||
if input == '' then
|
||||
s:update(id, { recur = vim.NIL, recur_mode = vim.NIL })
|
||||
_save_and_notify()
|
||||
buffer.render(bufnr)
|
||||
log.info('Task #' .. id .. ': recurrence removed.')
|
||||
return
|
||||
end
|
||||
local raw_spec = input
|
||||
local rec_mode = nil
|
||||
if raw_spec:sub(1, 1) == '!' then
|
||||
rec_mode = 'completion'
|
||||
raw_spec = raw_spec:sub(2)
|
||||
end
|
||||
local recur = require('pending.recur')
|
||||
if not recur.validate(raw_spec) then
|
||||
log.error('Invalid recurrence pattern: ' .. input)
|
||||
return
|
||||
end
|
||||
s:update(id, { recur = raw_spec, recur_mode = rec_mode })
|
||||
_save_and_notify()
|
||||
buffer.render(bufnr)
|
||||
log.info('Task #' .. id .. ': recurrence set to ' .. raw_spec .. '.')
|
||||
end)
|
||||
end
|
||||
|
||||
---@param text string
|
||||
---@return nil
|
||||
function M.add(text)
|
||||
|
|
@ -678,6 +923,7 @@ function M.add(text)
|
|||
due = metadata.due,
|
||||
recur = metadata.rec,
|
||||
recur_mode = metadata.rec_mode,
|
||||
priority = metadata.priority,
|
||||
})
|
||||
_save_and_notify()
|
||||
local bufnr = buffer.bufnr()
|
||||
|
|
@ -817,8 +1063,11 @@ local function parse_edit_token(token)
|
|||
local dk = cfg.date_syntax or 'due'
|
||||
local rk = cfg.recur_syntax or 'rec'
|
||||
|
||||
if token == '+!' then
|
||||
return 'priority', 1, nil
|
||||
local bangs = token:match('^%+(!+)$')
|
||||
if bangs then
|
||||
local max = cfg.max_priority or 3
|
||||
local level = math.min(#bangs, max)
|
||||
return 'priority', level, nil
|
||||
end
|
||||
if token == '-!' then
|
||||
return 'priority', 0, nil
|
||||
|
|
@ -881,21 +1130,33 @@ local function parse_edit_token(token)
|
|||
.. rk
|
||||
end
|
||||
|
||||
---@param id_str string
|
||||
---@param rest string
|
||||
---@param id_str? string
|
||||
---@param rest? string
|
||||
---@return nil
|
||||
function M.edit(id_str, rest)
|
||||
if not id_str or id_str == '' then
|
||||
log.error(
|
||||
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]'
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local id = tonumber(id_str)
|
||||
local id = id_str and tonumber(id_str)
|
||||
if not id then
|
||||
log.error('Invalid task ID: ' .. id_str)
|
||||
return
|
||||
local bufnr = buffer.bufnr()
|
||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||
local row = vim.api.nvim_win_get_cursor(0)[1]
|
||||
local meta = buffer.meta()
|
||||
if meta[row] and meta[row].type == 'task' and meta[row].id then
|
||||
id = meta[row].id
|
||||
if id_str and id_str ~= '' then
|
||||
rest = rest and (id_str .. ' ' .. rest) or id_str
|
||||
end
|
||||
end
|
||||
end
|
||||
if not id then
|
||||
if id_str and id_str ~= '' then
|
||||
log.error('Invalid task ID: ' .. id_str)
|
||||
else
|
||||
log.error(
|
||||
'Usage: :Pending edit [<id>] [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]'
|
||||
)
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local s = get_store()
|
||||
|
|
@ -955,7 +1216,11 @@ function M.edit(id_str, rest)
|
|||
end
|
||||
elseif field == 'priority' then
|
||||
updates.priority = value
|
||||
table.insert(feedback, value == 1 and 'priority added' or 'priority removed')
|
||||
if value == 0 then
|
||||
table.insert(feedback, 'priority removed')
|
||||
else
|
||||
table.insert(feedback, 'priority set to ' .. value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue