From 356cc199fab16c7efe198bc5ab18b340ca180942 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:08:10 -0500 Subject: [PATCH] feat: warn on dirty buffer before store-dependent actions (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `` (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 --- lua/pending/buffer.lua | 1 + lua/pending/init.lua | 60 +++++++++++++++++++++++++++++++++++++----- lua/pending/views.lua | 2 ++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 827ff82..adcf2dc 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -133,6 +133,7 @@ function M.open_line(above) local insert_row = above and (row - 1) or row vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) + table.insert(_meta, insert_row + 1, { type = 'task' }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 36f5282..0fd3a98 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -83,6 +83,16 @@ local function _save_and_notify() M._recompute_counts() end +---@return boolean +local function require_saved() + local bufnr = buffer.bufnr() + if bufnr and vim.bo[bufnr].modified then + log.warn('save changes first (:w)') + return false + end + return true +end + ---@return pending.Counts function M.counts() if not _counts then @@ -175,6 +185,9 @@ end ---@param pred_str string ---@return nil function M.filter(pred_str) + if not require_saved() then + return + end if pred_str == 'clear' or pred_str == '' then buffer.set_filter({}, {}) local bufnr = buffer.bufnr() @@ -232,7 +245,7 @@ end function M._setup_buf_mappings(bufnr) local cfg = require('pending.config').get() local km = cfg.keymaps - local opts = { buffer = bufnr, silent = true } + local opts = { buffer = bufnr, silent = true, nowait = true } ---@type table local actions = { @@ -243,6 +256,9 @@ function M._setup_buf_mappings(bufnr) M.toggle_complete() end, view = function() + if not require_saved() then + return + end buffer.toggle_view() end, priority = function() @@ -255,6 +271,9 @@ function M._setup_buf_mappings(bufnr) M.undo_write() end, filter = function() + if not require_saved() then + return + end vim.ui.input({ prompt = 'Filter: ' }, function(input) if input then M.filter(input) @@ -370,6 +389,9 @@ end ---@return nil function M.undo_write() + if not require_saved() then + return + end local s = get_store() local stack = s:undo_stack() if #stack == 0 then @@ -388,6 +410,9 @@ function M.toggle_complete() 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 @@ -430,13 +455,30 @@ function M.toggle_complete() end end ----@param id_str string +---@param id_str? string ---@return nil function M.done(id_str) - local id = tonumber(id_str) - if not id then - log.error('Invalid task ID: ' .. tostring(id_str)) - return + local id + if not id_str or id_str == '' then + 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 + log.error('Cursor is not on a task line.') + return + end + id = meta[row].id + if not id then + return + end + else + id = tonumber(id_str) + if not id then + log.error('Invalid task ID: ' .. tostring(id_str)) + return + end end local s = get_store() s:load() @@ -478,6 +520,9 @@ function M.toggle_priority() 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 @@ -510,6 +555,9 @@ function M.prompt_date() 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 diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 87fcee1..3b67f90 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -156,6 +156,7 @@ function M.category_view(tasks) 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, @@ -207,6 +208,7 @@ function M.priority_view(tasks) 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,