Compare commits

...

10 commits

Author SHA1 Message Date
b57cdd20b0 feat(plugin): add tab completion for :Pending auth subcommands
`:Pending auth <Tab>` completes `gcal gtasks clear reset`;
`:Pending auth <backend> <Tab>` completes `clear reset`.
2026-03-06 12:33:58 -05:00
c45aacfcbb 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.
2026-03-06 12:33:53 -05:00
cfdcff9eba 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`.
2026-03-06 12:33:50 -05:00
fadad3ed95 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.
2026-03-06 12:33:45 -05:00
628286c471 fix(init): tighten dirty-buffer warning message 2026-03-06 12:03:50 -05:00
06a325baa4 fix(init): improve dirty-buffer warning message 2026-03-06 12:01:11 -05:00
edd1750a0e 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.
2026-03-06 12:00:15 -05:00
23ae390f23 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.
2026-03-06 11:49:37 -05:00
2a654ad27d fix(buffer): use task sentinel in open_line for better unsaved-task errors 2026-03-06 11:42:15 -05:00
133369b968 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.
2026-03-06 11:40:11 -05:00
6 changed files with 77 additions and 30 deletions

View file

@ -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

View file

@ -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()
@ -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
@ -435,6 +460,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 +471,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 +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
@ -525,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
@ -885,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
@ -919,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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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