Problem: `%l` was dead code after inline overlays replaced EOL
rendering. Auto-close was always on with no opt-out. Forge icon
defaults were empty strings.
Solution: remove `%l` from the eol format parser and renderer. Add
`forge.auto_close` (default `false`) to gate state-pull. Set nerd
font icons: `` (GitHub), `` (GitLab), `` (Codeberg). Keep
conceal active in insert mode via `concealcursor = 'nic'`.
Problem: forge links are now rendered inline, making the `%l` EOL
specifier redundant in the default format.
Solution: change default `eol_format` from `'%l %c %r %d'` to
`'%c %r %d'`. The `%l` specifier remains functional for users who
explicitly set it.
Problem: forge tokens were stripped from the buffer and shown as EOL
virtual text via `%l`. The token disappeared from the editable line,
and multi-ref tasks broke.
Solution: compute `forge_spans` in `views.lua` with byte offsets for
each forge token in the rendered line. In `apply_inline_row()`, place
extmarks with `conceal=''` and `virt_text_pos='inline'` to visually
replace each raw token with its formatted label. Clear stale
`forge_spans` on dirty rows to prevent `end_col` out-of-range errors
after edits like `dd`.
Problem: `parse.body()` stripped forge tokens from the description,
losing the raw text. This made inline overlay rendering impossible
since the token no longer existed in the buffer.
Solution: remove the `forge.parse_ref()` branch from `parse.body()`
and call `forge.find_refs()` in `diff.parse_buffer()` instead. The
description now retains forge tokens verbatim; `_extra._forge_ref`
is still populated from the first matched ref.
Problem: forge tokens were extracted by `parse.body()` which stripped
them from the description, making editing awkward and multi-ref lines
impossible.
Solution: add `find_refs(text)` that scans a string for all forge
tokens by whitespace tokenization, returning byte offsets and parsed
refs without modifying the input. Remove unused `conceal_patterns()`.
Problem: forge metadata was never fetched, so virt text highlights
could not reflect remote issue/PR state.
Solution: call `forge.refresh()` in `M.open()` so metadata is
fetched once per `:Pending` invocation rather than on every render.
Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens,
requiring users to type owner/repo from memory.
Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and
complete with `owner/repo#` candidates from existing forge refs in
the store.
Problem: forge tokens were visible as raw text with no virtual text
labels, and the eol separator logic collapsed all gaps when
non-adjacent specifiers were absent.
Solution: add forge conceal syntax patterns in `setup_syntax()`, add
`PendingForge`/`PendingForgeClosed` highlight groups, handle the
`%l` specifier in `build_eol_virt()`, fix separator collapsing to
buffer one separator between present segments, and change
`concealcursor` to `nc` (reveal in visual and insert mode).
Problem: `LineMeta` had no forge fields, so `buffer.lua` could not
render forge labels or apply forge-specific highlights.
Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`,
populated from `task._extra` in both `category_view` and
`priority_view`.
Problem: forge refs parsed from buffer lines were discarded during
diff reconciliation and never stored in the JSON.
Solution: thread `forge_ref` through `parse_buffer` entries into
`diff.apply`, storing it in `task._extra._forge_ref` for both new
and existing tasks.
Problem: `parse.body()` had no awareness of forge link tokens, so
`gh:user/repo#42` stayed in the description instead of metadata.
Solution: add `forge_ref` field to `pending.Metadata` and extend the
right-to-left token loop in `body()` to call `forge.parse_ref()` as
the final fallback before breaking.
Problem: no configuration surface for forge link rendering, icons,
issue format, or self-hosted instances.
Solution: add `pending.ForgeConfig` class with per-forge `token`,
`icon`, `issue_format`, and `instances` fields. Add `%l` to the
default `eol_format` so forge labels render in virtual text.
Problem: no way to associate tasks with GitHub, GitLab, or Codeberg
issues/PRs, or to track their remote state.
Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full
URL parsing, async metadata fetching via `curl`, label formatting,
conceal pattern generation, token resolution, and `refresh()` for
state pull (closed/merged -> done).
Problem: `with_token()` recommended the generic `:Pending auth` when
credentials were missing, even though the backend was already known.
Solution: append the backend name so the message reads e.g.
`:Pending auth gtasks` instead of `:Pending auth`.
Problem: running a sync action (e.g. `:Pending gtasks push`) without
being authenticated would silently abort with a warning, requiring
the user to manually run `:Pending auth` first.
Solution: `oauth.with_token()` now auto-triggers the browser auth flow
when no token exists (for non-bundled credentials) and resumes the
original action on success. `auth()` and `_exchange_code()` now call
`on_complete(ok)` on all exit paths. S3 backends run
`aws sts get-caller-identity` before every sync action, auto-triggering
SSO login on expired sessions.
* fix(sync): add backend name prefix to all OAuth log messages (#121)
Problem: four log messages in `oauth.lua` lacked the `self.name` backend
prefix, producing generic notifications instead of identifying which
backend (`gcal`/`gtasks`) triggered the message.
Solution: prepend `self.name .. ': '` to the four unprefixed messages
and drop the hardcoded "Google" from the browser prompt since `self.name`
already identifies the service.
* fix(sync): canonicalize all log prefixes across sync backends (#121)
Problem: log messages in `oauth.lua`, `gcal.lua`, `gtasks.lua`, and
`s3.lua` were inconsistent — some lacked a backend prefix, others used
sentence-case or bare error strings without identifying the source.
Solution: prefix all user-facing log messages with their backend name
(`gcal:`, `gtasks:`, `S3:`, `Google:`). Capitalize `S3` and `Google`
display names. Normalize casing and separator style (em dash) across
all sync log sites.
* fix(buffer): escape hyphens in `infer_status` Lua patterns
Problem: `infer_status` used `/-` in its Lua patterns, which is a lazy
quantifier on `/` rather than a literal hyphen. This caused the function
to always return `nil` for lines with an `/id/` prefix, so status was
never inferred from buffer text during `reapply_dirty_inline`.
Solution: escape hyphens as `%-` in both patterns. Also add debug
logging to `on_bytes`, `reapply_dirty_inline`, `apply_extmarks`, and
the `TextChanged`/`TextChangedI`/`InsertLeave` autocmds.
* ci: format
* fix(config): update default keymaps to match vimdoc
Problem: four keymap defaults in `config.lua` still used the old
deprecated keys (`!`, `D`, `U`, `F`) while `doc/pending.txt` documents
the `g`-prefixed replacements (`g!`, `gd`, `gz`, `gf`).
Solution: update `priority`, `date`, `undo`, and `filter` defaults to
`g!`, `gd`, `gz`, and `gf` respectively.
* fix(buffer): correct extmark drift on `open_line` above/below done tasks
Problem: `open_line` used `nvim_buf_set_lines` which triggered `on_bytes`
with a `start_row` offset designed for native `o`/`O` keypresses. The
`_meta` entry was inserted one position too late, causing the done task's
`PendingDone` highlight to attach to the new blank line instead.
Solution: suppress `on_bytes` during `open_line` by reusing the
`_rendering` guard, insert the meta entry at the correct position, and
immediately reapply inline extmarks for the affected rows.
* fix(buffer): infer task status from line text in `reapply_dirty_inline`
Problem: `on_bytes` inserts bare `{ type = 'task' }` meta entries with
no `status` field for any new lines (paste, undo, native edits). When
meta positions also shift incorrectly (e.g. `P` paste above), existing
meta with the wrong status ends up on the wrong row. This causes done
tasks to lose their `PendingDone` highlight and pending tasks to appear
greyed out.
Solution: always re-infer `status` from the actual buffer line text for
dirty task rows before applying extmarks. The checkbox character (`[x]`,
`[>]`, `[=]`, `[ ]`) is the source of truth, with fallback to the
existing meta status if the line doesn't match a task pattern.
Problem: four keymap defaults in `config.lua` still used the old
deprecated keys (`!`, `D`, `U`, `F`) while `doc/pending.txt` documents
the `g`-prefixed replacements (`g!`, `gd`, `gz`, `gf`).
Solution: update `priority`, `date`, `undo`, and `filter` defaults to
`g!`, `gd`, `gz`, and `gf` respectively.
* feat(s3): create bucket interactively during auth when unconfigured
Problem: when a user runs `:Pending s3 auth` with no bucket configured,
auth succeeds but offers no way to create the bucket. The user must
manually run `aws s3api create-bucket` and update their config.
Solution: add `util.input()` coroutine-aware prompt wrapper and a
`create_bucket()` flow in `s3.lua` that prompts for bucket name and
region, handles the `us-east-1` LocationConstraint quirk, and logs a
config snippet on success. Called automatically from `auth()` when
`sync.s3.bucket` is absent.
* ci: typing
* feat(parse): add `parse_duration_to_days` for duration string conversion
Problem: The archive command accepted only a bare integer for days,
inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere.
Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`,
and bare integers. Returns nil on invalid input for caller error handling.
* feat(archive): duration syntax and confirmation prompt
Problem: `:Pending archive` accepted only a bare integer for days and
silently deleted tasks with no confirmation, risking accidental data loss.
Solution: Accept duration strings (`7d`, `3w`, `2m`) via
`parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation
prompt before removing tasks, and skip the prompt when zero tasks match.
* feat: add `<C-a>` / `<C-x>` keymaps for priority increment/decrement
Problem: Priority could only be cycled with `g!` (0→1→2→3→0), with no
way to directly increment or decrement.
Solution: Add `adjust_priority()` with clamping at 0 and `max_priority`,
exposed as `increment_priority()` / `decrement_priority()` on `<C-a>` /
`<C-x>`. Includes `<Plug>` mappings and vimdoc.
* fix(s3): use parenthetical defaults in bucket creation prompts
Problem: `util.input` with `default` pre-filled the input field, and
the success message said "Add to your config" ambiguously.
Solution: Show defaults in prompt text as `(default)` instead of
pre-filling, and clarify the message to "Add to your pending.nvim
config".
* ci: format
* ci(sync): normalize log prefix to `backend:` across all sync backends
Problem: Sync log messages used inconsistent prefixes like `s3 push:`,
`gtasks pull:`, `gtasks sync —` instead of the `backend: action` pattern
used by auth messages.
Solution: Normalize all sync backend logs to `backend: action ...` format
across `s3.lua`, `gcal.lua`, and `gtasks.lua`.
* ci: fix linter warnings in archive spec and s3 bucket creation
* feat(s3): create bucket interactively during auth when unconfigured
Problem: when a user runs `:Pending s3 auth` with no bucket configured,
auth succeeds but offers no way to create the bucket. The user must
manually run `aws s3api create-bucket` and update their config.
Solution: add `util.input()` coroutine-aware prompt wrapper and a
`create_bucket()` flow in `s3.lua` that prompts for bucket name and
region, handles the `us-east-1` LocationConstraint quirk, and logs a
config snippet on success. Called automatically from `auth()` when
`sync.s3.bucket` is absent.
* ci: typing
* feat(parse): add `parse_duration_to_days` for duration string conversion
Problem: The archive command accepted only a bare integer for days,
inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere.
Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`,
and bare integers. Returns nil on invalid input for caller error handling.
* feat(archive): duration syntax and confirmation prompt
Problem: `:Pending archive` accepted only a bare integer for days and
silently deleted tasks with no confirmation, risking accidental data loss.
Solution: Accept duration strings (`7d`, `3w`, `2m`) via
`parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation
prompt before removing tasks, and skip the prompt when zero tasks match.
* feat: add `<C-a>` / `<C-x>` keymaps for priority increment/decrement
Problem: Priority could only be cycled with `g!` (0→1→2→3→0), with no
way to directly increment or decrement.
Solution: Add `adjust_priority()` with clamping at 0 and `max_priority`,
exposed as `increment_priority()` / `decrement_priority()` on `<C-a>` /
`<C-x>`. Includes `<Plug>` mappings and vimdoc.
* fix(s3): use parenthetical defaults in bucket creation prompts
Problem: `util.input` with `default` pre-filled the input field, and
the success message said "Add to your config" ambiguously.
Solution: Show defaults in prompt text as `(default)` instead of
pre-filling, and clarify the message to "Add to your pending.nvim
config".
* ci: format
* feat(s3): create bucket interactively during auth when unconfigured
Problem: when a user runs `:Pending s3 auth` with no bucket configured,
auth succeeds but offers no way to create the bucket. The user must
manually run `aws s3api create-bucket` and update their config.
Solution: add `util.input()` coroutine-aware prompt wrapper and a
`create_bucket()` flow in `s3.lua` that prompts for bucket name and
region, handles the `us-east-1` LocationConstraint quirk, and logs a
config snippet on success. Called automatically from `auth()` when
`sync.s3.bucket` is absent.
* ci: typing
* feat(parse): add `parse_duration_to_days` for duration string conversion
Problem: The archive command accepted only a bare integer for days,
inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere.
Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`,
and bare integers. Returns nil on invalid input for caller error handling.
* feat(archive): duration syntax and confirmation prompt
Problem: `:Pending archive` accepted only a bare integer for days and
silently deleted tasks with no confirmation, risking accidental data loss.
Solution: Accept duration strings (`7d`, `3w`, `2m`) via
`parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation
prompt before removing tasks, and skip the prompt when zero tasks match.
* feat(s3): create bucket interactively during auth when unconfigured
Problem: when a user runs `:Pending s3 auth` with no bucket configured,
auth succeeds but offers no way to create the bucket. The user must
manually run `aws s3api create-bucket` and update their config.
Solution: add `util.input()` coroutine-aware prompt wrapper and a
`create_bucket()` flow in `s3.lua` that prompts for bucket name and
region, handles the `us-east-1` LocationConstraint quirk, and logs a
config snippet on success. Called automatically from `auth()` when
`sync.s3.bucket` is absent.
* ci: typing
* refactor(types): extract inline anonymous types into named classes
Problem: several functions used inline `{...}` table types in their
`@param` and `@return` annotations, making them hard to read and
impossible to reference from other modules.
Solution: extract each into a named `---@class`: `pending.Metadata`,
`pending.TaskFields`, `pending.CompletionItem`, `pending.SystemResult`,
and `pending.OAuthClientOpts`.
* refactor(sync): extract shared utilities into `sync/util.lua`
Problem: sync epilogue code (`s:save()`, `_recompute_counts()`,
`buffer.render()`) and `fmt_counts` were duplicated across `gcal.lua`
and `gtasks.lua`. The concurrency guard lived in `oauth.lua`, coupling
non-OAuth backends to the OAuth module.
Solution: create `sync/util.lua` with `async`, `system`, `with_guard`,
`finish`, and `fmt_counts`. Delegate from `oauth.lua` and replace
duplicated code in both backends. Add per-backend `auth()` and
`auth_complete()` methods to `gcal.lua` and `gtasks.lua`.
* feat(sync): auto-discover backends, per-backend auth, S3 backend
Problem: sync backends were hardcoded in `SYNC_BACKENDS` list in
`init.lua`, auth routed directly through `oauth.google_client`, and
adding a non-OAuth backend required editing multiple files.
Solution: replace hardcoded list with `discover_backends()` that globs
`lua/pending/sync/*.lua` at runtime. Rewrite `M.auth()` to dispatch
to per-backend `auth()` methods with `vim.ui.select` fallback. Add
`lua/pending/sync/s3.lua` with push/pull/sync via AWS CLI, per-task
merge by `_s3_sync_id` (UUID), and `pending.S3Config` type.
Problem: several functions used inline `{...}` table types in their
`@param` and `@return` annotations, making them hard to read and
impossible to reference from other modules.
Solution: extract each into a named `---@class`: `pending.Metadata`,
`pending.TaskFields`, `pending.CompletionItem`, `pending.SystemResult`,
and `pending.OAuthClientOpts`.
Problem: the task editing surface had gaps — category and recurrence had
no keymaps, `:Pending edit` required knowing the task ID, tasks couldn't
be reordered with a keymap, priority was binary (0/1), and `wip`/`blocked`
states were documented but unimplemented.
Solution: fill every cell so every property is editable in every way.
- `gc`/`gr` keymaps for category select and recurrence prompt
- cursor-aware `:Pending edit` (omit ID to use task under cursor)
- `J`/`K` keymaps to reorder tasks within a category
- multi-level priorities (`max_priority` config, `g!` cycles 0→1→2→3→0)
- `+!!`/`+!!!` tokens in `:Pending edit`, `:Pending add`, `parse.body()`
- `PendingPriority2`/`PendingPriority3` highlight groups
- `gw`/`gb` keymaps toggle `wip`/`blocked` status
- `>`/`=` state chars in buffer rendering and diff parsing
- `PendingWip`/`PendingBlocked` highlight groups
- sort order: wip → pending → blocked → done
- `wip`/`blocked` filter predicates and icons
Problem: View-related config fields (`default_view`, `eol_format`,
`category_order`, `folding`) are scattered as top-level siblings
alongside unrelated fields like `data_path` and `date_syntax`.
Solution: Group them under a `view` table with per-view sub-tables:
`view.default`, `view.eol_format`, `view.category.order`,
`view.category.folding`, and `view.queue` (empty, ready for #100).
Update all call sites, tests, and vimdoc.
* refactor(buffer): split extmark namespace into `ns_eol` and `ns_inline`
Problem: all extmarks shared a single `pending` namespace, making it
impossible to selectively clear position-sensitive extmarks (overlays,
highlights) while preserving stable EOL virtual text (due dates,
recurrence).
Solution: introduce `ns_eol` for end-of-line virtual text and
`ns_inline` for overlays and highlights. `clear_marks()` and
`apply_extmarks()` operate on both namespaces independently.
* feat(buffer): track line changes via `on_bytes` to keep `_meta` aligned
Problem: `_meta` is a positional array keyed by line number. Line
insertions and deletions during editing desync it from actual buffer
content, breaking `get_fold()`, cursor-based task lookups, and extmark
re-application.
Solution: attach an `on_bytes` callback that adjusts `_meta` on line
insertions/deletions and tracks dirty rows. Remove the manual
`_meta` insert from `open_line()` since `on_bytes` now handles it.
Reset dirty rows on each full render.
* feat(buffer): clear only inline extmarks on dirty rows during edits
Problem: `TextChanged` cleared all extmarks (both namespaces) on every
edit, causing EOL virtual text (due dates, recurrence) to vanish while
the user types.
Solution: replace blanket `clear_marks()` with per-row
`clear_inline_row()` that only removes `ns_inline` extmarks on rows
flagged dirty by `on_bytes`. EOL virtual text is preserved untouched.
* feat(buffer): re-apply inline extmarks after edits
Problem: inline extmarks (checkbox overlays, strikethrough, header
highlights) were cleared during edits and only restored on `:w`,
leaving the buffer visually bare while editing.
Solution: extract `apply_inline_row()` from `apply_extmarks()` and
call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode
`TextChanged`. Insert-mode `TextChangedI` still only clears inline
marks on dirty rows to avoid overlay flicker while typing.
* fix(buffer): suppress `on_bytes` during render and fix definition order
Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`,
corrupting `_meta` with duplicate entries and causing out-of-range
extmark errors. Also, `apply_inline_row` was defined after its first
caller `reapply_dirty_inline`.
Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in
`render()` so `on_bytes` is a no-op during authoritative renders.
Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua
local scoping rules.
* feat(buffer): add configurable `eol_format` for EOL virtual text
Problem: EOL virtual text order (category → recurrence → due) and the
double-space separator are hardcoded in `apply_extmarks()`. Users cannot
reorder, omit, or restyle metadata fields.
Solution: Add `eol_format` config field (default `'%c %r %d'`) with
`%c`, `%r`, `%d` specifiers. `parse_eol_format()` tokenizes the format
string; `build_eol_virt()` resolves specifiers against `LineMeta` and
collapses literals around absent fields.
* ci: format
* refactor(buffer): split extmark namespace into `ns_eol` and `ns_inline`
Problem: all extmarks shared a single `pending` namespace, making it
impossible to selectively clear position-sensitive extmarks (overlays,
highlights) while preserving stable EOL virtual text (due dates,
recurrence).
Solution: introduce `ns_eol` for end-of-line virtual text and
`ns_inline` for overlays and highlights. `clear_marks()` and
`apply_extmarks()` operate on both namespaces independently.
* feat(buffer): track line changes via `on_bytes` to keep `_meta` aligned
Problem: `_meta` is a positional array keyed by line number. Line
insertions and deletions during editing desync it from actual buffer
content, breaking `get_fold()`, cursor-based task lookups, and extmark
re-application.
Solution: attach an `on_bytes` callback that adjusts `_meta` on line
insertions/deletions and tracks dirty rows. Remove the manual
`_meta` insert from `open_line()` since `on_bytes` now handles it.
Reset dirty rows on each full render.
* feat(buffer): clear only inline extmarks on dirty rows during edits
Problem: `TextChanged` cleared all extmarks (both namespaces) on every
edit, causing EOL virtual text (due dates, recurrence) to vanish while
the user types.
Solution: replace blanket `clear_marks()` with per-row
`clear_inline_row()` that only removes `ns_inline` extmarks on rows
flagged dirty by `on_bytes`. EOL virtual text is preserved untouched.
* feat(buffer): re-apply inline extmarks after edits
Problem: inline extmarks (checkbox overlays, strikethrough, header
highlights) were cleared during edits and only restored on `:w`,
leaving the buffer visually bare while editing.
Solution: extract `apply_inline_row()` from `apply_extmarks()` and
call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode
`TextChanged`. Insert-mode `TextChangedI` still only clears inline
marks on dirty rows to avoid overlay flicker while typing.
* fix(buffer): suppress `on_bytes` during render and fix definition order
Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`,
corrupting `_meta` with duplicate entries and causing out-of-range
extmark errors. Also, `apply_inline_row` was defined after its first
caller `reapply_dirty_inline`.
Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in
`render()` so `on_bytes` is a no-op during authoritative renders.
Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua
local scoping rules.
Problem: folded category headers are lost when Neovim exits because
`_fold_state` only lives in memory. Users must re-fold categories
every session.
Solution: store folded category names in the JSON data file as a
top-level `folded_categories` field. On first render, `restore_folds`
seeds from the store instead of the empty in-memory state. Folds are
persisted on `M.close()` and `VimLeavePre`.
Problem: `:Pending due` quickfix items landed on line 1 instead of
the task line. The `BufEnter` redirect branch captured cursor before
quickfix had positioned it in the new window, so the stale position
was used when transferring focus back to the registered pending window.
Solution: move cursor capture inside `vim.schedule` so it reads after
quickfix navigation has completed. Also guard `clear_marks` behind a
`modified` check so extmarks are only cleared on actual edits.
Problem: Deleting lines (`dd`, `dat`, `d3j`) left extmarks stranded on
adjacent rows since `render()` only clears and reapplies marks on `:w`.
Quickfix `<CR>` opened the pending buffer in a second window because
`BufEnter` did not redirect to `task_winid`. Category fold state was
lost across `<Tab>/<Tab>` view toggles because `render()` overwrote the
saved state with an empty snapshot taken while folds were disabled.
Solution: Add a `TextChanged`/`TextChangedI` autocmd that clears the
extmark namespace immediately on any edit. Fix `BufEnter` to close
duplicate windows and redirect focus to `task_winid`, updating it when
stale. Fix `snapshot_folds` to skip if a state is already saved, and
`restore_folds` to always clear the saved state; snapshot in
`toggle_view` before the view flips so the state survives the round-trip.
Problem: category folds were hardcoded with no config option, no custom
foldtext, and no vimdoc coverage.
Solution: add `folding` config field (boolean or table with `foldtext`
format string). Default foldtext is `%c (%n tasks)` with automatic
singular/plural. Gate all fold logic on the config so `folding = false`
disables folds entirely. Document the new option in vimdoc.
Problem: Log messages used inconsistent capitalization, punctuation,
and phrasing — some started lowercase, some omitted periods, "Pending"
was used instead of "task", and sync backend errors used ad-hoc
formatting.
Solution: Apply sentence case after backend prefixes, add trailing
periods to complete sentences, rename "Pending" to "task", use
`'Failed to <verb> <noun>: '` pattern for operation errors, and
pluralize "Archived N task(s)" correctly.
* fix(gtasks): prevent concurrent push/pull from racing on the store
Problem: `push` and `pull` both run via `oauth.async`, so issuing them
back-to-back starts two coroutines that interleave at every curl yield.
Both snapshot `build_id_index` before either has mutated the store,
which can cause push to create a remote task that pull would have
recognized as already linked, producing duplicates on Google.
Solution: guard `with_token` with a module-level `_in_flight` flag set
before `oauth.async` is called so no second operation can start during
a token-refresh yield. A `pcall` around the callback guarantees the
flag is always cleared, even on an unexpected error.
* refactor(sync): centralize `with_token` in oauth.lua with shared lock
Problem: `with_token` was duplicated in `gcal.lua` and `gtasks.lua`,
with the concurrency lock added only to the gtasks copy. Any new
backend would silently inherit the same race, and gcal back-to-back
push could still create duplicate remote calendar events.
Solution: lift `with_token` into `oauth.lua` as
`M.with_token(client, name, callback)` behind a module-level
`_sync_in_flight` guard. All backends share one implementation; the
lock covers gcal, gtasks, and any future backend automatically.
* ci: format
* fix(oauth): resolve re-auth deadlock and improve flow robustness
Problem: in-flight TCP server held port 18392 for up to 120 seconds.
Calling `auth()` again caused `bind()` to fail silently — the browser
opened but no listener could receive the OAuth callback. `_wipe()` on
exchange failure also destroyed credentials, forcing full re-setup.
Solution: `_active_close` at module scope cancels any in-flight server
when `auth()` or `clear_tokens()` is called. Binding is guarded with
`pcall`; the browser only opens after the server is listening. Swapped
`_wipe()` for `clear_tokens()` in `_exchange_code` to preserve
credentials on failure. Added `select_account` to `prompt` so Google
always shows the account picker on re-auth.
* test(oauth): isolate bundled-credentials fallback from real filesystem
Problem: `resolve_credentials` reads from `vim.fn.stdpath('data')`,
the real Neovim data dir. The test passed only because `_wipe()` was
incidentally deleting the user's credential file mid-run.
Solution: stub `oauth.load_json_file` for the duration of the test so
real credential files cannot interfere with the fallback assertion.
* ci: format
* fix(sync): replace cryptic sigil counters with readable output
Problem: sync summaries used unexplained sigils (`+/-/~` and `!`) that
conveyed no meaning, mixed symbol and prose formats across operations,
and `gcal push` silently swallowed failures with no aggregate counter.
Solution: replace all summary `log.info` calls with a shared
`fmt_counts` helper that formats `N label` pairs separated by ` | `,
suppresses zero counts, and falls back to "nothing to do". Add a
`failed` counter to `gcal.push` to surface errors previously only
emitted as individual warnings.
* ci: format
Problem: push/sync permanently deleted remote Google Calendar events and
Google Tasks entries whenever a local task was marked deleted, done, or
de-due'd. There was no opt-out, so a misfire could silently cause
irreversible data loss on the remote side.
Solution: add a `remote_delete` boolean to the config (default `false`).
A unified flag at `sync.remote_delete` sets the base; per-backend
overrides at `sync.gcal.remote_delete` / `sync.gtasks.remote_delete`
take precedence when non-nil. When disabled, `_extra` remote IDs are
cleared silently (unlinking) so stale IDs don't accumulate.
* 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 `<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.
* 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 <Tab>` completes `gcal gtasks clear reset`;
`:Pending auth <backend> <Tab>` completes `clear reset`.
* 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 `<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.
* 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
* 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.
Toggles a task's done/pending status by ID from the command line,
matching the buffer \`<CR>\` behaviour including recurrence spawning.
Tab-completes active task IDs.
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.