From fc58e22bfa0f523714836e0d78a9f168a1ca2a11 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 23:15:43 -0500 Subject: [PATCH 1/2] fix(sync): auth and health UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Failed token exchange left credential files on disk, trapping users in a broken auth loop with no way back to setup. The `auth` prompt used raw backend names and a terse prompt string. The `health` action appeared in `:Pending gcal health` tab completion but silently no-oped outside `:checkhealth`. gcal health omitted the token check that gtasks had. Solution: `_exchange_code` now calls `_wipe()` on both failure paths, clearing the token and credentials files so the next `:Pending auth` routes back through `setup()`. Prompt uses full service names and "Authenticate with:". `health` is filtered from sync subcommand completion and dispatch — its home is `:checkhealth pending`. gcal health now checks for tokens. --- lua/pending/init.lua | 8 ++++---- lua/pending/sync/gcal.lua | 6 ++++++ lua/pending/sync/oauth.lua | 8 ++++++++ plugin/pending.lua | 2 +- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 446d375..8ede23a 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -550,7 +550,7 @@ local function run_sync(backend_name, action) if not action or action == '' then local actions = {} for k, v in pairs(backend) do - if type(v) == 'function' and k:sub(1, 1) ~= '_' then + if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then table.insert(actions, k) end end @@ -558,7 +558,7 @@ local function run_sync(backend_name, action) log.info(backend_name .. ' actions: ' .. table.concat(actions, ', ')) return end - if type(backend[action]) ~= 'function' then + if action == 'health' or type(backend[action]) ~= 'function' then log.error(backend_name .. " backend has no '" .. action .. "' action") return end @@ -831,8 +831,8 @@ end ---@return nil function M.auth() local oauth = require('pending.sync.oauth') - vim.ui.select({ 'gtasks', 'gcal', 'both' }, { - prompt = 'Authenticate:', + vim.ui.select({ 'Google Tasks', 'Google Calendar', 'Google Tasks and Google Calendar' }, { + prompt = 'Authenticate with:', }, function(choice) if not choice then return diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index f90d7c1..942fbec 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -234,6 +234,12 @@ end ---@return nil function M.health() oauth.health(M.name) + local tokens = oauth.google_client:load_tokens() + if tokens and tokens.refresh_token then + vim.health.ok('gcal tokens found') + else + vim.health.info('no gcal tokens — run :Pending auth') + end end return M diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 887769c..bc00208 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -470,12 +470,14 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet }, { text = true }) if result.code ~= 0 then + self:_wipe() log.error('Token exchange failed.') return end local ok, decoded = pcall(vim.json.decode, result.stdout or '') if not ok or not decoded.access_token then + self:_wipe() log.error('Invalid token response.') return end @@ -488,6 +490,12 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet end end +---@return nil +function OAuthClient:_wipe() + os.remove(self:token_path()) + os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json') +end + ---@param opts { name: string, scope: string, port: integer, config_key: string } ---@return pending.OAuthClient function M.new(opts) diff --git a/plugin/pending.lua b/plugin/pending.lua index cba4916..93a0b11 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -216,7 +216,7 @@ end, { end local actions = {} for k, v in pairs(mod) do - if type(v) == 'function' and k:sub(1, 1) ~= '_' then + if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then table.insert(actions, k) end end From 8fb3554c436dbcffbb723fe6f92513864ec27716 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 23:52:56 -0500 Subject: [PATCH 2/2] feat: add \`:Pending done \` command Toggles a task's done/pending status by ID from the command line, matching the buffer \`\` behaviour including recurrence spawning. Tab-completes active task IDs. --- lua/pending/init.lua | 44 ++++++++++++++++++++++++++++++++++++++ lua/pending/sync/oauth.lua | 2 +- plugin/pending.lua | 12 ++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 8ede23a..36f5282 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -430,6 +430,48 @@ function M.toggle_complete() end end +---@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 + end + local s = get_store() + s:load() + local task = s:get(id) + if not task then + log.error('No task with ID ' .. id .. '.') + return + end + local was_done = task.status == 'done' + if was_done then + s:update(id, { status = 'pending', ['end'] = vim.NIL }) + else + if task.recur and task.due then + local recur = require('pending.recur') + local mode = task.recur_mode or 'scheduled' + local next_date = recur.next_due(task.due, task.recur, mode) + s:add({ + description = task.description, + category = task.category, + priority = task.priority, + due = next_date, + recur = task.recur, + recur_mode = task.recur_mode, + }) + end + s:update(id, { status = 'done' }) + end + _save_and_notify() + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + log.info('Task #' .. id .. ' marked ' .. (was_done and 'pending' or 'done')) +end + ---@return nil function M.toggle_priority() local bufnr = buffer.bufnr() @@ -856,6 +898,8 @@ function M.command(args) local cmd, rest = args:match('^(%S+)%s*(.*)') if cmd == 'add' then M.add(rest) + elseif cmd == 'done' then + M.done(rest:match('^(%S+)')) elseif cmd == 'edit' then local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') M.edit(id_str, edit_rest) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index bc00208..224476b 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -350,7 +350,7 @@ end function OAuthClient:auth(on_complete) local creds = self:resolve_credentials() if creds.client_id == BUNDLED_CLIENT_ID then - log.error(self.name .. ': no credentials configured — run :Pending ' .. self.name .. ' setup') + log.error(self.name .. ': no credentials configured — run :Pending auth') return end local port = self.port diff --git a/plugin/pending.lua b/plugin/pending.lua index 93a0b11..d246fba 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -167,7 +167,7 @@ end, { nargs = '*', complete = function(arg_lead, cmd_line) local pending = require('pending') - local subcmds = { 'add', 'archive', 'auth', 'due', 'edit', 'filter', 'undo' } + local subcmds = { 'add', 'archive', 'auth', 'done', 'due', 'edit', 'filter', 'undo' } for _, b in ipairs(pending.sync_backends()) do table.insert(subcmds, b) end @@ -200,6 +200,16 @@ end, { end return filtered end + if cmd_line:match('^Pending%s+done%s') then + local store = require('pending.store') + local s = store.new(store.resolve_path()) + s:load() + local ids = {} + for _, task in ipairs(s:active_tasks()) do + table.insert(ids, tostring(task.id)) + end + return filter_candidates(arg_lead, ids) + end if cmd_line:match('^Pending%s+edit') then return complete_edit(arg_lead, cmd_line) end