From 1e2196fe2eb189e8554abe6925bee3c3cb433f57 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:36:47 -0500 Subject: [PATCH] feat: :Pending auth subcommands + fix #61 (#84) 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 * feat(oauth): add `OAuthClient:clear_tokens()` method 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. * 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`. * 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. * feat(plugin): add tab completion for `:Pending auth` subcommands `:Pending auth ` completes `gcal gtasks clear reset`; `:Pending auth ` completes `clear reset`. --- lua/pending/init.lua | 29 ++++++++++++++++++++--------- lua/pending/sync/gcal.lua | 11 +---------- lua/pending/sync/gtasks.lua | 11 +---------- lua/pending/sync/oauth.lua | 5 +++++ plugin/pending.lua | 15 +++++++++++++++ 5 files changed, 42 insertions(+), 29 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) 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) 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) 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