From 133369b968af16718eee6202daba9d8692925e83 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 11:40:11 -0500 Subject: [PATCH 01/10] fix(buffer): keep `_meta` in sync when `open_line` inserts a new line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lua/pending/buffer.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 827ff82..ad626f3 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 = 'blank' }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end From 2a654ad27d284b6aea7ece159127b74eadb7df7d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 11:42:15 -0500 Subject: [PATCH 02/10] fix(buffer): use task sentinel in `open_line` for better unsaved-task errors --- lua/pending/buffer.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index ad626f3..adcf2dc 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -133,7 +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 = 'blank' }) + table.insert(_meta, insert_row + 1, { type = 'task' }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end From 23ae390f23ba12702ba42c4bbbaccd696ddfda86 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 11:49:37 -0500 Subject: [PATCH 03/10] 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. --- lua/pending/init.lua | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 06c722c..d25b100 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('Unsaved changes — :w first.') + return false + end + return true +end + ---@return pending.Counts function M.counts() if not _counts then @@ -388,6 +398,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 @@ -435,6 +448,9 @@ end function M.done(id_str) 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 @@ -443,7 +459,6 @@ function M.done(id_str) end id = meta[row].id if not id then - log.error('Task has no ID — save the buffer first.') return end else @@ -493,6 +508,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 @@ -525,6 +543,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 From edd1750a0ec2547c56b2f74fbe598d73ffb2f169 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 12:00:15 -0500 Subject: [PATCH 04/10] 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. --- lua/pending/init.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d25b100..f89ffe1 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -185,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() @@ -253,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() @@ -265,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) @@ -380,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 From 06a325baa4719ef4f36d4b9f8413d1b4ee78ffc1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 12:01:11 -0500 Subject: [PATCH 05/10] fix(init): improve dirty-buffer warning message --- lua/pending/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index f89ffe1..3da747d 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -87,7 +87,7 @@ end local function require_saved() local bufnr = buffer.bufnr() if bufnr and vim.bo[bufnr].modified then - log.warn('Unsaved changes — :w first.') + log.warn('Buffer has unsaved changes — write with :w first.') return false end return true From 628286c471b643ee40e9bc5a8e875286ff5163e2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 12:03:50 -0500 Subject: [PATCH 06/10] fix(init): tighten dirty-buffer warning message --- lua/pending/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 3da747d..0fd3a98 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -87,7 +87,7 @@ end local function require_saved() local bufnr = buffer.bufnr() if bufnr and vim.bo[bufnr].modified then - log.warn('Buffer has unsaved changes — write with :w first.') + log.warn('save changes first (:w)') return false end return true From fadad3ed95de6f743ce4c4c4c9abe80ec148879c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 12:33:45 -0500 Subject: [PATCH 07/10] feat(oauth): add `OAuthClient:clear_tokens()` method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: no way to wipe just the token file while keeping credentials intact — `_wipe()` removed both. Solution: add `clear_tokens()` that removes only the token file. --- lua/pending/sync/oauth.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 224476b..8539ea6 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -496,6 +496,11 @@ function OAuthClient:_wipe() os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json') end +---@return nil +function OAuthClient:clear_tokens() + os.remove(self:token_path()) +end + ---@param opts { name: string, scope: string, port: integer, config_key: string } ---@return pending.OAuthClient function M.new(opts) From cfdcff9eba4ef7c1663237892f35e9492e6d0541 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 12:33:50 -0500 Subject: [PATCH 08/10] fix(sync): warn instead of auto-reauth when token is missing Problem: `with_token` silently triggered an OAuth browser flow when no tokens existed, with no user-facing explanation. Solution: replace the auto-reauth branch with a `log.warn` directing the user to run `:Pending auth`. --- lua/pending/sync/gcal.lua | 11 +---------- lua/pending/sync/gtasks.lua | 11 +---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 942fbec..1fe8557 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -134,16 +134,7 @@ local function with_token(callback) oauth.async(function() local token = oauth.google_client:get_access_token() if not token then - oauth.google_client:auth(function() - oauth.async(function() - local fresh = oauth.google_client:get_access_token() - if fresh then - callback(fresh) - else - log.error(oauth.google_client.name .. ': authorization failed or was cancelled') - end - end) - end) + log.warn('not authenticated — run :Pending auth') return end callback(token) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 9fc7459..6c8bef3 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -404,16 +404,7 @@ local function with_token(callback) oauth.async(function() local token = oauth.google_client:get_access_token() if not token then - oauth.google_client:auth(function() - oauth.async(function() - local fresh = oauth.google_client:get_access_token() - if fresh then - callback(fresh) - else - log.error(oauth.google_client.name .. ': authorization failed or was cancelled') - end - end) - end) + log.warn('not authenticated — run :Pending auth') return end callback(token) From c45aacfcbb1dfa96efa6002c88f8c350f6afe017 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 12:33:53 -0500 Subject: [PATCH 09/10] feat(init): add `clear` and `reset` actions to `:Pending auth` Problem: no CLI path existed to wipe stale tokens or reset credentials, and the `vim.ui.select` backend picker was misleading given shared tokens. Solution: accept an args string in `M.auth()`, dispatching `clear` to `clear_tokens()`, `reset` to `_wipe()`, and bare backend names to the existing auth flow. Remove the picker. --- lua/pending/init.lua | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 0fd3a98..4e093bf 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -918,22 +918,33 @@ function M.edit(id_str, rest) log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ')) end +---@param args? string ---@return nil -function M.auth() +function M.auth(args) local oauth = require('pending.sync.oauth') - vim.ui.select({ 'Google Tasks', 'Google Calendar', 'Google Tasks and Google Calendar' }, { - prompt = 'Authenticate with:', - }, function(choice) - if not choice then - return - end + local parts = {} + for w in (args or ''):gmatch('%S+') do + table.insert(parts, w) + end + local action = parts[#parts] + if action == parts[1] and (action == 'gtasks' or action == 'gcal') then + action = nil + end + + if action == 'clear' then + oauth.google_client:clear_tokens() + log.info('OAuth tokens cleared — run :Pending auth to re-authenticate.') + elseif action == 'reset' then + oauth.google_client:_wipe() + log.info('OAuth tokens and credentials cleared — run :Pending auth to set up from scratch.') + else local creds = oauth.google_client:resolve_credentials() if creds.client_id == oauth.BUNDLED_CLIENT_ID then oauth.google_client:setup() else oauth.google_client:auth() end - end) + end end ---@param args string @@ -952,7 +963,7 @@ function M.command(args) local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') M.edit(id_str, edit_rest) elseif cmd == 'auth' then - M.auth() + M.auth(rest) elseif SYNC_BACKEND_SET[cmd] then local action = rest:match('^(%S+)') run_sync(cmd, action) From b57cdd20b0c0f21dc8caaa33eef148e2234345d1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 12:33:58 -0500 Subject: [PATCH 10/10] feat(plugin): add tab completion for `:Pending auth` subcommands `:Pending auth ` completes `gcal gtasks clear reset`; `:Pending auth ` completes `clear reset`. --- plugin/pending.lua | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugin/pending.lua b/plugin/pending.lua index d246fba..e456f09 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -213,6 +213,21 @@ end, { if cmd_line:match('^Pending%s+edit') then return complete_edit(arg_lead, cmd_line) end + if cmd_line:match('^Pending%s+auth') then + local after_auth = cmd_line:match('^Pending%s+auth%s+(.*)') or '' + local parts = {} + for w in after_auth:gmatch('%S+') do + table.insert(parts, w) + end + local trailing = after_auth:match('%s$') + if #parts == 0 or (#parts == 1 and not trailing) then + return filter_candidates(arg_lead, { 'gcal', 'gtasks', 'clear', 'reset' }) + end + if #parts == 1 or (#parts == 2 and not trailing) then + return filter_candidates(arg_lead, { 'clear', 'reset' }) + end + return {} + end local backend_set = pending.sync_backend_set() local matched_backend = cmd_line:match('^Pending%s+(%S+)') if matched_backend and backend_set[matched_backend] then