Commit graph

112 commits

Author SHA1 Message Date
fe5ea8be78 fix(forge): fix ghost extmarks, false auth warnings, and needless API calls
Problem: extmarks ghosted after `cc`/undo on task lines, auth warnings
fired even when CLIs were authenticated, and `refresh()` hit forge APIs
on every buffer open regardless of `auto_close`.

Solution: add `invalidate = true` to all extmarks so Neovim cleans them
up on text deletion. Run `auth status` before warning to verify the CLI
is actually unauthenticated. Gate `refresh()` behind `auto_close` config.
2026-03-10 23:01:33 -04:00
e62f2f818c ci: format 2026-03-10 22:16:15 -04:00
0033b26e38 refactor(forge): extract ForgeBackend class and registry
Problem: adding a new forge required touching 5 lookup tables
(`FORGE_HOSTS`, `FORGE_CLI`, `FORGE_AUTH_CMD`, `SHORTHAND_PREFIX`,
`_warned_forges`) and every branching site in `_api_args`,
`fetch_metadata`, and `parse_ref`.

Solution: introduce a `ForgeBackend` class with `parse_url`,
`api_args`, and `parse_state` methods, plus a `register()` /
`backends()` registry. New forges (Gitea, Forgejo) are a single
`register()` call via the `gitea_backend()` convenience constructor.
2026-03-10 22:11:37 -04:00
ecacb62674 refactor(forge): replace curl/token auth with CLI-native API calls
Problem: Forge metadata fetching required manual token management —
config fields, CLI token extraction, and curl with auth headers. Each
forge had a different auth path, and Codeberg had no CLI support at all.

Solution: Delete `get_token()` and `_api_url()`, replace with
`_api_args()` that builds `gh api`, `glab api`, or `tea api` arg
arrays. The CLIs handle auth internally. Add `warn_missing_cli` config
(default true) that warns once per forge per session on failure. Add
forge CLI checks to `:checkhealth`. Remove `token` from config/docs.
2026-03-10 21:42:55 -04:00
dc58af30f9 fix(config): set correct nerd font icons for forge defaults 2026-03-10 19:58:07 -04:00
36898898a7 refactor(forge): remove %l eol specifier, add auto_close config, fix icons
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'`.
2026-03-10 19:54:42 -04:00
e764b34ecb ci: format 2026-03-10 19:28:36 -04:00
dee79320c8 fix(config): remove %l from default eol_format
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.
2026-03-10 18:58:42 -04:00
819d27d751 feat(buffer): render forge links as inline conceal overlays
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`.
2026-03-10 18:58:36 -04:00
0a64691edd refactor: move forge ref detection from parse.body() to diff
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.
2026-03-10 18:58:24 -04:00
e98b5a8e4a feat(forge): add find_refs() inline token scanner
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()`.
2026-03-10 18:58:00 -04:00
6dc64855b1 feat: trigger forge refresh on buffer open
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.
2026-03-10 17:44:42 -04:00
3a994e6284 feat(complete): add forge shorthand omnifunc completions
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.
2026-03-10 17:44:36 -04:00
f4e62b148c feat(buffer): render forge links as concealed text with eol virt text
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).
2026-03-10 17:44:30 -04:00
7405390fd9 feat(views): pass forge ref and cache to line metadata
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`.
2026-03-10 17:44:22 -04:00
253ec9664f feat(diff): persist forge refs in store on write
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.
2026-03-10 17:44:15 -04:00
3a3e5b0505 feat(parse): extract forge refs from task body
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.
2026-03-10 17:44:10 -04:00
46b87e8f30 feat(config): add forge config defaults and %l eol specifier
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.
2026-03-10 17:44:04 -04:00
6c6e62a2e2 feat(forge): add forge link parser and metadata fetcher
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).
2026-03-10 17:43:59 -04:00
Barrett Ruth
fec03b3dcd
fix(sync): include backend name in bundled-creds auth recommendation (#124)
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`.
2026-03-10 11:43:51 -04:00
Barrett Ruth
be3d9b777e
fix(sync): auto-trigger auth flow on unauthenticated sync actions (#120) (#123)
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.
2026-03-10 11:37:16 -04:00
Barrett Ruth
914633235a
fix(sync): add backend name prefix to all OAuth log messages (#122)
* 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.
2026-03-10 11:26:16 -04:00
Barrett Ruth
1eb2e49096
fix(buffer): escape hyphens in infer_status Lua patterns (#119)
* 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
2026-03-09 00:31:13 -04:00
Barrett Ruth
a38be10e67
fix(buffer): correct extmark drift on open_line for done tasks (#118)
* 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.
2026-03-09 00:28:58 -04:00
Barrett Ruth
96577890cb
fix(config): update default keymaps to match vimdoc (#116)
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.
2026-03-09 00:10:09 -04:00
Barrett Ruth
c37cf7cc3a
fix(sync): normalize log prefixes and S3 prompt UX (#115)
* 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
2026-03-08 20:56:22 -04:00
Barrett Ruth
9672af7c08
feat: add <C-a> / <C-x> keymaps for priority increment/decrement (#114)
* 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
2026-03-08 20:49:05 -04:00
Barrett Ruth
dc365e266b
feat(archive): duration syntax and confirmation prompt (#113)
* 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.
2026-03-08 20:28:06 -04:00
Barrett Ruth
7640241cf2
feat(sync): s3 backend (#112)
* 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
2026-03-08 20:20:16 -04:00
ee75e6844e ci: format 2026-03-08 19:54:06 -04:00
Barrett Ruth
fe4c1d0e31
feat: auth backend (#111)
* 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.
2026-03-08 19:53:42 -04:00
Barrett Ruth
ac02526cf1
refactor(types): extract inline anonymous types into named classes (#110)
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`.
2026-03-08 19:49:49 -04:00
Barrett Ruth
b06249f101
feat: complete task editing coverage (#109)
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
2026-03-08 19:44:03 -04:00
Barrett Ruth
a43f769383
refactor(config): nest view settings under view key (#103)
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.
2026-03-08 19:13:17 -04:00
Barrett Ruth
c9471ebe90
feat: persistent inline extmarks and configurable EOL format (#97)
* 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
2026-03-08 14:28:10 -04:00
Barrett Ruth
ab06cfcf69
feat(buffer): persist extmarks during editing (#96)
* 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.
2026-03-08 14:19:47 -04:00
d06731a7fd ci: format 2026-03-08 13:24:44 -04:00
Barrett Ruth
9af6086959
feat(buffer): persist fold state across sessions (#94)
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
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`.
2026-03-07 20:18:34 -05:00
79aeeba9bb fix: minor login
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-03-07 01:54:09 -05:00
522daf3a21 ci: format 2026-03-07 01:38:24 -05:00
Barrett Ruth
d176ccccd1
fix(init): fix cursor position when navigating from quickfix (#93)
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.
2026-03-07 01:38:12 -05:00
5fe6dcecad ci: cleanup 2026-03-06 21:36:46 -05:00
Barrett Ruth
559ab863a8
fix(buffer): fix stale extmarks, duplicate window, and fold state loss (#92)
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.
2026-03-06 21:36:04 -05:00
Barrett Ruth
c392874311
feat(buffer): add configurable category-level folds (#91)
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.
2026-03-06 20:08:49 -05:00
Barrett Ruth
12b9295c34
refactor: normalize log message grammar and capitalization (#89)
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.
2026-03-06 18:38:17 -05:00
Barrett Ruth
874ff381f9
fix: resolve OAuth re-auth deadlock and sync concurrency races (#88)
* 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
2026-03-06 16:09:45 -05:00
Barrett Ruth
b641c93a0a
fix(oauth): resolve re-auth deadlock and improve flow robustness (#87)
* 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
2026-03-06 15:47:42 -05:00
Barrett Ruth
ff73164b61
fix(sync): replace cryptic sigil counters with readable output (#86)
* 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
2026-03-06 13:26:23 -05:00
Barrett Ruth
991ac5b467
feat(sync): add opt-in remote deletion for gcal and gtasks (#85)
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.
2026-03-06 13:12:53 -05:00
Barrett Ruth
1e2196fe2e
feat: :Pending auth subcommands + fix #61 (#84)
* 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`.
2026-03-06 12:36:47 -05:00