pending.nvim/lua/pending/views.lua
Barrett Ruth 356cc199fa feat: warn on dirty buffer before store-dependent actions (#83)
* fix(buffer): use `default_category` config for empty placeholder

Problem: The empty-buffer fallback hardcoded the category name `TODO`,
ignoring the user's `default_category` config value (default: `Todo`).

Solution: Read `config.get().default_category` at render time and use
that value for both the header line and `LineMeta` category field.

* fix(diff): match optional checkbox char in `parse_buffer` patterns

Problem: `parse_buffer` used `%[.%]` which requires exactly one
character between brackets, failing to parse empty `[]` checkboxes.

Solution: Change to `%[.?%]` so the character is optional, matching
`[]`, `[ ]`, `[x]`, and `[!]` uniformly.

* fix(init): add `nowait` to buffer keymap opts

Problem: Buffer-local mappings like `!` could be swallowed by Neovim's
operator-pending machinery or by global maps sharing a prefix, since
the keymap opts did not include `nowait`.

Solution: Add `nowait = true` to the shared `opts` table used for all
buffer-local mappings in `_setup_buf_mappings`.

* feat(init): allow `:Pending done` with no args to use cursor line

Problem: `:Pending done` required an explicit task ID, making it
awkward to mark the current task done while inside the pending buffer.

Solution: When called with no ID, `M.done()` reads the cursor row from
`buffer.meta()` to resolve the task ID, erroring if the cursor is not
on a saved task line.

* fix(views): populate `priority` field in `LineMeta`

Problem: Both `category_view` and `priority_view` omitted `priority`
from the `LineMeta` they produced. `apply_extmarks` checks `m.priority`
to decide whether to render the priority icon, so it was always nil,
causing the `[ ]` pending-icon overlay to replace the `[!]` buffer text.

Solution: Add `priority = task.priority` to both LineMeta constructors.

* fix(buffer): keep `_meta` in sync when `open_line` inserts a new line

Problem: `open_line` inserted a buffer line without updating `_meta`,
leaving the entry at that row pointing to the task that was shifted
down. Pressing `<CR>` (toggle_complete) would read the stale meta,
find a real task ID, toggle it, and re-render — destroying the unsaved
new line.

Solution: Insert a `{ type = 'blank' }` sentinel into `_meta` at the
new line's position so buffer-local actions see no task there.

* fix(buffer): use task sentinel in `open_line` for better unsaved-task errors

* feat(init): warn on dirty buffer before store-dependent actions

Problem: `toggle_complete`, `toggle_priority`, `prompt_date`, and
`done` (no-args) all read from `buffer.meta()` which is stale whenever
the buffer has unsaved edits, leading to silent no-ops or acting on the
wrong task.

Solution: Add a `require_saved()` guard that emits a `log.warn` and
returns false when the buffer is modified. Each store-dependent action
calls it before touching meta or the store.

* fix(init): guard `view`, `undo`, and `filter` against dirty buffer

Problem: `toggle_view`, `undo_write`, and `filter` all call
`buffer.render()` which rewrites the buffer from the store, silently
discarding any unsaved edits. The previous `require_saved()` change
missed these three entry points.

Solution: Add `require_saved()` to the `view` and `filter` keymap
lambdas and to `M.undo_write()`. Also guard `M.filter()` directly so
`:Pending filter` from the command line is covered too.

* fix(init): improve dirty-buffer warning message

* fix(init): tighten dirty-buffer warning message
2026-03-06 12:08:10 -05:00

221 lines
5.3 KiB
Lua

local config = require('pending.config')
local parse = require('pending.parse')
---@class pending.LineMeta
---@field type 'task'|'header'|'blank'|'filter'
---@field id? integer
---@field due? string
---@field raw_due? string
---@field status? string
---@field category? string
---@field overdue? boolean
---@field show_category? boolean
---@field priority? integer
---@field recur? string
---@class pending.views
local M = {}
---@param due? string
---@return string?
local function format_due(due)
if not due then
return nil
end
local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
if not y then
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
end
if not y then
return due
end
local t = os.time({
year = tonumber(y) --[[@as integer]],
month = tonumber(m) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
})
local formatted = os.date(config.get().date_format, t) --[[@as string]]
if hh then
formatted = formatted .. ' ' .. hh .. ':' .. mm
end
return formatted
end
---@param tasks pending.Task[]
local function sort_tasks(tasks)
table.sort(tasks, function(a, b)
if a.priority ~= b.priority then
return a.priority > b.priority
end
if a.order ~= b.order then
return a.order < b.order
end
return a.id < b.id
end)
end
---@param tasks pending.Task[]
local function sort_tasks_priority(tasks)
table.sort(tasks, function(a, b)
if a.priority ~= b.priority then
return a.priority > b.priority
end
local a_due = a.due or ''
local b_due = b.due or ''
if a_due ~= b_due then
if a_due == '' then
return false
end
if b_due == '' then
return true
end
return a_due < b_due
end
if a.order ~= b.order then
return a.order < b.order
end
return a.id < b.id
end)
end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
function M.category_view(tasks)
local by_cat = {}
local cat_order = {}
local cat_seen = {}
local done_by_cat = {}
for _, task in ipairs(tasks) do
local cat = task.category or config.get().default_category
if not cat_seen[cat] then
cat_seen[cat] = true
table.insert(cat_order, cat)
by_cat[cat] = {}
done_by_cat[cat] = {}
end
if task.status == 'done' then
table.insert(done_by_cat[cat], task)
else
table.insert(by_cat[cat], task)
end
end
local cfg_order = config.get().category_order
if cfg_order and #cfg_order > 0 then
local ordered = {}
local seen = {}
for _, name in ipairs(cfg_order) do
if cat_seen[name] then
table.insert(ordered, name)
seen[name] = true
end
end
for _, name in ipairs(cat_order) do
if not seen[name] then
table.insert(ordered, name)
end
end
cat_order = ordered
end
for _, cat in ipairs(cat_order) do
sort_tasks(by_cat[cat])
sort_tasks(done_by_cat[cat])
end
local lines = {}
local meta = {}
for i, cat in ipairs(cat_order) do
if i > 1 then
table.insert(lines, '')
table.insert(meta, { type = 'blank' })
end
table.insert(lines, '# ' .. cat)
table.insert(meta, { type = 'header', category = cat })
local all = {}
for _, t in ipairs(by_cat[cat]) do
table.insert(all, t)
end
for _, t in ipairs(done_by_cat[cat]) do
table.insert(all, t)
end
for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
local line = prefix .. '- [' .. state .. '] ' .. task.description
table.insert(lines, line)
table.insert(meta, {
type = 'task',
id = task.id,
due = format_due(task.due),
raw_due = task.due,
status = task.status,
category = cat,
priority = task.priority,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due)
or nil,
recur = task.recur,
})
end
end
return lines, meta
end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
function M.priority_view(tasks)
local pending = {}
local done = {}
for _, task in ipairs(tasks) do
if task.status == 'done' then
table.insert(done, task)
else
table.insert(pending, task)
end
end
sort_tasks_priority(pending)
sort_tasks_priority(done)
local lines = {}
local meta = {}
local all = {}
for _, t in ipairs(pending) do
table.insert(all, t)
end
for _, t in ipairs(done) do
table.insert(all, t)
end
for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
local line = prefix .. '- [' .. state .. '] ' .. task.description
table.insert(lines, line)
table.insert(meta, {
type = 'task',
id = task.id,
due = format_due(task.due),
raw_due = task.due,
status = task.status,
category = task.category,
priority = task.priority,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil,
show_category = true,
recur = task.recur,
})
end
return lines, meta
end
return M