Problem: `auto_close` is verbose given it's already namespaced under
`forge.` in the config table.
Solution: Rename to `close` in config defaults, class annotation,
`refresh()` usage, and vimdoc.
* docs: document S3 backend, auto-auth, and `:Pending done` command
Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS
section, `:Pending auth` only mentioned Google, the `sync` config
field omitted `s3`, `_s3_sync_id` was missing from the data format
section, `:Pending done` was implemented but undocumented, and the
README lacked a features overview.
Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite
`:Pending auth` to cover all backends and sub-actions, update config
and data format references, add `aws` CLI to requirements, and add a
Features section to `README.md`.
* 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).
* 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.
* 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.
* 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.
* 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`.
* 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).
* 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.
* 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.
* test(forge): add forge parsing spec
Problem: no test coverage for forge link shorthand parsing, URL
parsing, label formatting, or API URL generation.
Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`,
`parse_ref` for all three forges, full URL parsing including nested
GitLab groups, `format_label`, and `_api_url`.
* docs: document forge links feature
Problem: no user-facing documentation for forge link syntax,
configuration, or behavior.
Solution: add forge links section to `README.md` and `pending.txt`
covering shorthand/URL syntax, config options, virtual text
rendering, state pull, and auth resolution.
* 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()`.
* 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.
* 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`.
* 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.
* test(forge): update specs for inline forge refs
Problem: existing tests asserted that `parse.body()` stripped forge
tokens from the description and populated `meta.forge_ref`. The
`conceal_patterns` test referenced a removed function.
Solution: update `parse.body` integration tests to assert tokens stay
in the description. Add `find_refs()` tests covering single/multiple
refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns`
test. Update diff tests to assert description includes the token.
* docs: update forge links for inline overlay rendering
Problem: documentation described forge tokens as stripped from the
description and rendered via EOL `%l` specifier by default.
Solution: update forge links section to describe inline conceal
overlay rendering. Update default `eol_format` reference. Change
`issue_format` field description from "EOL label" to "inline overlay
label".
* ci: format
* 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'`.
* fix(config): set correct nerd font icons for forge defaults
* 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.
* 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.
* ci: format
* feat(forge): support custom shorthand prefixes
Problem: forge shorthand parsing hardcoded `%l%l` (exactly 2 lowercase
letters), preventing custom prefixes like `github:`. Completions also
hardcoded `gh:`, `gl:`, `cb:` patterns.
Solution: iterate `_by_shorthand` keys dynamically in `_parse_shorthand`
instead of matching a fixed pattern. Build completion patterns from
`forge.backends()`. Add `shorthand` field to `ForgeInstanceConfig` so
users can override prefixes via config, applied in `_ensure_instances()`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* docs: document S3 backend, auto-auth, and `:Pending done` command
Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS
section, `:Pending auth` only mentioned Google, the `sync` config
field omitted `s3`, `_s3_sync_id` was missing from the data format
section, `:Pending done` was implemented but undocumented, and the
README lacked a features overview.
Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite
`:Pending auth` to cover all backends and sub-actions, update config
and data format references, add `aws` CLI to requirements, and add a
Features section to `README.md`.
* 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).
* 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.
* 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.
* 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.
* 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`.
* 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).
* 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.
* 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.
* test(forge): add forge parsing spec
Problem: no test coverage for forge link shorthand parsing, URL
parsing, label formatting, or API URL generation.
Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`,
`parse_ref` for all three forges, full URL parsing including nested
GitLab groups, `format_label`, and `_api_url`.
* docs: document forge links feature
Problem: no user-facing documentation for forge link syntax,
configuration, or behavior.
Solution: add forge links section to `README.md` and `pending.txt`
covering shorthand/URL syntax, config options, virtual text
rendering, state pull, and auth resolution.
* 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()`.
* 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.
* 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`.
* 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.
* test(forge): update specs for inline forge refs
Problem: existing tests asserted that `parse.body()` stripped forge
tokens from the description and populated `meta.forge_ref`. The
`conceal_patterns` test referenced a removed function.
Solution: update `parse.body` integration tests to assert tokens stay
in the description. Add `find_refs()` tests covering single/multiple
refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns`
test. Update diff tests to assert description includes the token.
* docs: update forge links for inline overlay rendering
Problem: documentation described forge tokens as stripped from the
description and rendered via EOL `%l` specifier by default.
Solution: update forge links section to describe inline conceal
overlay rendering. Update default `eol_format` reference. Change
`issue_format` field description from "EOL label" to "inline overlay
label".
* ci: format
* 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'`.
* fix(config): set correct nerd font icons for forge defaults
* 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.
* 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.
* ci: format
* feat(diff): disallow editing done tasks by default
Problem: Done tasks could be freely edited in the buffer, leading to
accidental modifications of completed work.
Solution: Add a `lock_done` config option (default `true`) and a guard
in `diff.apply()` that rejects field changes to done tasks unless the
user toggles the checkbox back to pending first.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* docs: document S3 backend, auto-auth, and `:Pending done` command
Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS
section, `:Pending auth` only mentioned Google, the `sync` config
field omitted `s3`, `_s3_sync_id` was missing from the data format
section, `:Pending done` was implemented but undocumented, and the
README lacked a features overview.
Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite
`:Pending auth` to cover all backends and sub-actions, update config
and data format references, add `aws` CLI to requirements, and add a
Features section to `README.md`.
* 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).
* 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.
* 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.
* 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.
* 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`.
* 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).
* 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.
* 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.
* test(forge): add forge parsing spec
Problem: no test coverage for forge link shorthand parsing, URL
parsing, label formatting, or API URL generation.
Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`,
`parse_ref` for all three forges, full URL parsing including nested
GitLab groups, `format_label`, and `_api_url`.
* docs: document forge links feature
Problem: no user-facing documentation for forge link syntax,
configuration, or behavior.
Solution: add forge links section to `README.md` and `pending.txt`
covering shorthand/URL syntax, config options, virtual text
rendering, state pull, and auth resolution.
* 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()`.
* 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.
* 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`.
* 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.
* test(forge): update specs for inline forge refs
Problem: existing tests asserted that `parse.body()` stripped forge
tokens from the description and populated `meta.forge_ref`. The
`conceal_patterns` test referenced a removed function.
Solution: update `parse.body` integration tests to assert tokens stay
in the description. Add `find_refs()` tests covering single/multiple
refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns`
test. Update diff tests to assert description includes the token.
* docs: update forge links for inline overlay rendering
Problem: documentation described forge tokens as stripped from the
description and rendered via EOL `%l` specifier by default.
Solution: update forge links section to describe inline conceal
overlay rendering. Update default `eol_format` reference. Change
`issue_format` field description from "EOL label" to "inline overlay
label".
* ci: format
* 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'`.
* fix(config): set correct nerd font icons for forge defaults
* 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.
* 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.
* ci: format
* docs: document S3 backend, auto-auth, and `:Pending done` command
Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS
section, `:Pending auth` only mentioned Google, the `sync` config
field omitted `s3`, `_s3_sync_id` was missing from the data format
section, `:Pending done` was implemented but undocumented, and the
README lacked a features overview.
Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite
`:Pending auth` to cover all backends and sub-actions, update config
and data format references, add `aws` CLI to requirements, and add a
Features section to `README.md`.
* 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).
* 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.
* 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.
* 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.
* 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`.
* 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).
* 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.
* 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.
* test(forge): add forge parsing spec
Problem: no test coverage for forge link shorthand parsing, URL
parsing, label formatting, or API URL generation.
Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`,
`parse_ref` for all three forges, full URL parsing including nested
GitLab groups, `format_label`, and `_api_url`.
* docs: document forge links feature
Problem: no user-facing documentation for forge link syntax,
configuration, or behavior.
Solution: add forge links section to `README.md` and `pending.txt`
covering shorthand/URL syntax, config options, virtual text
rendering, state pull, and auth resolution.
* 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()`.
* 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.
* 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`.
* 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.
* test(forge): update specs for inline forge refs
Problem: existing tests asserted that `parse.body()` stripped forge
tokens from the description and populated `meta.forge_ref`. The
`conceal_patterns` test referenced a removed function.
Solution: update `parse.body` integration tests to assert tokens stay
in the description. Add `find_refs()` tests covering single/multiple
refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns`
test. Update diff tests to assert description includes the token.
* docs: update forge links for inline overlay rendering
Problem: documentation described forge tokens as stripped from the
description and rendered via EOL `%l` specifier by default.
Solution: update forge links section to describe inline conceal
overlay rendering. Update default `eol_format` reference. Change
`issue_format` field description from "EOL label" to "inline overlay
label".
* ci: format
* 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'`.
* fix(config): set correct nerd font icons for forge defaults
* docs: document S3 backend, auto-auth, and `:Pending done` command
Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS
section, `:Pending auth` only mentioned Google, the `sync` config
field omitted `s3`, `_s3_sync_id` was missing from the data format
section, `:Pending done` was implemented but undocumented, and the
README lacked a features overview.
Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite
`:Pending auth` to cover all backends and sub-actions, update config
and data format references, add `aws` CLI to requirements, and add a
Features section to `README.md`.
* 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).
* 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.
* 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.
* 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.
* 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`.
* 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).
* 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.
* 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.
* test(forge): add forge parsing spec
Problem: no test coverage for forge link shorthand parsing, URL
parsing, label formatting, or API URL generation.
Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`,
`parse_ref` for all three forges, full URL parsing including nested
GitLab groups, `format_label`, and `_api_url`.
* docs: document forge links feature
Problem: no user-facing documentation for forge link syntax,
configuration, or behavior.
Solution: add forge links section to `README.md` and `pending.txt`
covering shorthand/URL syntax, config options, virtual text
rendering, state pull, and auth resolution.
* 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()`.
* 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.
* 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`.
* 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.
* test(forge): update specs for inline forge refs
Problem: existing tests asserted that `parse.body()` stripped forge
tokens from the description and populated `meta.forge_ref`. The
`conceal_patterns` test referenced a removed function.
Solution: update `parse.body` integration tests to assert tokens stay
in the description. Add `find_refs()` tests covering single/multiple
refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns`
test. Update diff tests to assert description includes the token.
* docs: update forge links for inline overlay rendering
Problem: documentation described forge tokens as stripped from the
description and rendered via EOL `%l` specifier by default.
Solution: update forge links section to describe inline conceal
overlay rendering. Update default `eol_format` reference. Change
`issue_format` field description from "EOL label" to "inline overlay
label".
* ci: format
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.
* feat(sync): unify Google auth under :Pending auth
Problem: users had to run `:Pending gtasks auth` and `:Pending gcal
auth` separately, producing two token files and two browser consents
for the same Google account.
Solution: introduce `oauth.google_client` with combined tasks +
calendar scopes and a single `google_tokens.json`. Remove per-backend
`auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth`
that prompts with `vim.ui.select` and delegates to the shared client's
`setup()` or `auth()` based on credential availability.
* docs: update vimdoc for unified Google auth
Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks
auth` / `:Pending gcal auth` commands and separate token files, which no
longer exist after the auth unification.
Solution: add `:Pending auth` entry to COMMANDS and a new
`*pending-google-auth*` section covering the shared PKCE flow, combined
scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action
tables and update all cross-references to use `:Pending auth`.
* ci: format
* feat(sync): selective push, remote deletion detection, and gcal fix
Problem: `push_pass` updated all remote-linked tasks unconditionally,
causing unnecessary API calls and potential clobbering of remote edits
made between syncs. `pull`/`sync` never noticed when a task disappeared
from remote. `update_event` omitted `transparency` that `create_event`
set. Failure counts were absent from sync log summaries.
Solution: Introduce `_gtasks_synced_at` in `_extra` — stamped after
every successful push/pull create or update — so `push_pass` skips
tasks unchanged since last sync. Add `detect_remote_deletions` to
unlink local tasks whose remote entry disappeared from a successfully
fetched list. Surface failures as `!N` in all sync logs and
`unlinked: N` for pull/sync. Add `transparency = 'transparent'` to
`update_event`. Cover new behaviour with 7 tests in `gtasks_spec.lua`.
* ci: formt
* feat(sync): unify Google auth under :Pending auth
Problem: users had to run `:Pending gtasks auth` and `:Pending gcal
auth` separately, producing two token files and two browser consents
for the same Google account.
Solution: introduce `oauth.google_client` with combined tasks +
calendar scopes and a single `google_tokens.json`. Remove per-backend
`auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth`
that prompts with `vim.ui.select` and delegates to the shared client's
`setup()` or `auth()` based on credential availability.
* docs: update vimdoc for unified Google auth
Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks
auth` / `:Pending gcal auth` commands and separate token files, which no
longer exist after the auth unification.
Solution: add `:Pending auth` entry to COMMANDS and a new
`*pending-google-auth*` section covering the shared PKCE flow, combined
scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action
tables and update all cross-references to use `:Pending auth`.
* ci: format
* feat(sync): add `setup` command to configure credentials interactively
Problem: users had to manually create a JSON credentials file at the
correct path before authenticating, with no guidance from the plugin.
Solution: add `OAuthClient:setup()` that prompts for client ID and
secret via `vim.ui.input`, writes to the shared
`google_credentials.json`, then immediately starts the OAuth flow.
Expose as `:Pending {gtasks,gcal} setup`. Also extend
`resolve_credentials()` to fall back to a shared `google_credentials.json`
so one file covers both backends.
* fix(sync): improve `setup` input loop with validation and masking
Problem: `setup()` used async `vim.ui.input` for both prompts, causing
newline and re-prompt issues when validation failed. The secret was also
echoed in plain text.
Solution: switch to synchronous `vim.fn.input` / `vim.fn.inputsecret`
loops with `vim.cmd.redraw()` + `nvim_echo` for inline error display and
re-prompting. Validate client ID format and `GOCSPX-` secret prefix
before saving.
* fix(oauth): fix `ipairs` nil truncation in `resolve_credentials` and add file-path setup option
Problem: `resolve_credentials` built `cred_paths` with a potentially nil
first element (`credentials_path`), causing `ipairs` to stop immediately
and always fall through to bundled placeholder credentials.
Solution: build `cred_paths` without nil entries using `table.insert`.
Also add a `2. Load from JSON file path` option to `setup()` via
`vim.fn.inputlist`, with `vim.fn.expand` for `~`/`$HOME` support and
the `installed` wrapper unwrap.
* doc: cleanup
* ci: format
* fix(sync): surface auth failures and detect missing credentials
Problem: three silent failure paths remained in the sync auth flow —
`with_token` gave no feedback when auth was cancelled or failed,
`get_access_token` logged a generic message on refresh failure, and
`auth()` opened a browser with `PLACEHOLDER` credentials with no
Neovim-side error.
Solution: add `log.error` in `with_token` when `get_access_token`
returns nil after auth, improve the refresh-failure message to name
the backend and hint at re-auth, and guard `auth()` with a pre-flight
check that errors immediately when bundled placeholder credentials are
detected.
* feat(sync): add `setup` command to configure credentials interactively
Problem: users had to manually create a JSON credentials file at the
correct path before authenticating, with no guidance from the plugin.
Solution: add `OAuthClient:setup()` that prompts for client ID and
secret via `vim.ui.input`, writes to the shared
`google_credentials.json`, then immediately starts the OAuth flow.
Expose as `:Pending {gtasks,gcal} setup`. Also extend
`resolve_credentials()` to fall back to a shared `google_credentials.json`
so one file covers both backends.
* fix(sync): improve `setup` input loop with validation and masking
Problem: `setup()` used async `vim.ui.input` for both prompts, causing
newline and re-prompt issues when validation failed. The secret was also
echoed in plain text.
Solution: switch to synchronous `vim.fn.input` / `vim.fn.inputsecret`
loops with `vim.cmd.redraw()` + `nvim_echo` for inline error display and
re-prompting. Validate client ID format and `GOCSPX-` secret prefix
before saving.
* fix(oauth): fix `ipairs` nil truncation in `resolve_credentials` and add file-path setup option
Problem: `resolve_credentials` built `cred_paths` with a potentially nil
first element (`credentials_path`), causing `ipairs` to stop immediately
and always fall through to bundled placeholder credentials.
Solution: build `cred_paths` without nil entries using `table.insert`.
Also add a `2. Load from JSON file path` option to `setup()` via
`vim.fn.inputlist`, with `vim.fn.expand` for `~`/`$HOME` support and
the `installed` wrapper unwrap.
* doc: cleanup
* ci: format
* fix(sync): trigger auth then resume operation when not authenticated
Problem: `get_access_token()` called `auth()` then immediately tried to
load tokens, but `auth()` is async (TCP server + browser redirect), so
tokens were never present at that point. All sync operations silently
aborted when unauthenticated.
Solution: Remove the inline auth attempt from `get_access_token()` and
add an `on_complete` callback to `auth()` / `_exchange_code()`. Add a
`with_token(callback)` helper in `gtasks.lua` and `gcal.lua` that
triggers auth with the sync operation as the continuation, so
`push`/`pull`/`sync` resume automatically after the OAuth flow
completes.
* ci: format
* fix(diff): preserve due/rec when absent from buffer line
Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil`
whenever those fields aren't present as inline tokens in the buffer line.
Because metadata is rendered as virtual text (never in the line text),
every description edit silently clears due dates and recurrence rules.
Solution: Only update `due`, `recur`, and `recur_mode` in the existing-
task branch when the parsed entry actually contains them (non-nil). Users
can still set/change these inline by typing `due:<date>` or `rec:<rule>`;
clearing them requires `:Pending edit <id> -due`.
* refactor: remove project-local store discovery
Problem: `store.resolve_path()` searched upward for `.pending.json`,
silently splitting task data across multiple files depending on CWD.
Solution: `resolve_path()` now always returns `config.get().data_path`.
Remove `M.init()` and the `:Pending init` command and tab-completion
entry. Remove the project-local health message.
* refactor: extract log.lua, standardise [pending.nvim]: prefix
Problem: Notifications were scattered across files using bare
`vim.notify` with inconsistent `pending.nvim: ` prefixes, and the
`debug` guard in `textobj.lua` and `init.lua` was duplicated inline.
Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and
`debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires
when `config.debug = true` or the optional `override` param is `true`.
Replace all `vim.notify` callsites and remove inline debug guards.
* feat(parse): configurable input date formats
Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords;
users expecting locale-style dates like `03/15/2026` or `15-Mar-2026`
had no way to configure alternative input formats.
Solution: Add `input_date_formats` config field (string[]). Each entry
is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`,
`%e`, `%b`, `%B`. Formats are tried in order after built-in keywords
fail. When no year specifier is present the current or next year is
inferred. Update vimdoc and add 8 parse_spec tests.
* refactor(oauth): async coroutine support, pure-Lua PKCE, server hardening
Problem: OAuth module shelled out to openssl for PKCE, used blocking
`vim.system():wait()`, had a weak `os.time()` PRNG seed, and the TCP
callback server leaked on read errors with no timeout.
Solution: Add `M.system()` coroutine wrapper and `M.async()` helper,
replace openssl with `vim.fn.sha256` + `vim.base64.encode`, seed from
`vim.uv.hrtime()`, add `close_server()` guard with 120s timeout, and
close the server on read errors.
* fix(gtasks): async operations, error notifications, buffer refresh
Problem: Sync operations blocked the editor, `push_pass` silently
dropped delete/update/create API errors, and the buffer was not
re-rendered after push/pull/sync.
Solution: Wrap `push`, `pull`, `sync` in `oauth.async()`, add
`vim.notify` for all `push_pass` failure paths, and re-render the
pending buffer after each operation.
* fix(init): edit recompute, filter predicates, sync action listing
Problem: `M.edit()` skipped `_recompute_counts()` after saving,
`compute_hidden_ids` lacked `done`/`pending` predicates, and
`run_sync` defaulted to `sync` instead of listing available actions.
Solution: Replace `s:save()` with `_save_and_notify()` in `M.edit()`,
add `done` and `pending` filter predicates, and list backend actions
when no action is specified.
* refactor(gcal): per-category calendars, async push, error notifications
Problem: gcal used a single hardcoded calendar name, ran synchronously
blocking the editor, and silently dropped some API errors.
Solution: Fetch all calendars and map categories to calendars (creating
on demand), wrap push in `oauth.async()`, notify on individual API
failures, track `_gcal_calendar_id` in `_extra`, and remove the `$`
anchor from `next_day` pattern.
* refactor: formatting fixes, config cleanup, health simplification
Problem: Formatter disagreements in `init.lua` and `gtasks.lua`,
stale `calendar` field in gcal config, and redundant health checks
for data directory existence.
Solution: Apply stylua formatting, remove `calendar` field from
`pending.GcalConfig`, drop data-dir and no-file health messages,
add `done`/`pending` to filter tab-completion candidates.
* docs: update vimdoc for sync refactor, remove demo scripts
Problem: Docs still referenced openssl dependency, defaulting to `sync`
action, and the `calendar` config field. Demo scripts used the old
singleton `store` API.
Solution: Update vimdoc and README to reflect explicit actions, per-
category calendars, and pure-Lua PKCE. Remove stale demo scripts and
update sync specs to match new behavior.
* fix(types): correct LuaLS annotations in oauth and gcal
* feat(gtasks): add Google Tasks bidirectional sync
Problem: pending.nvim only supported one-way push to Google Calendar.
Users who use Google Tasks had no way to sync tasks bidirectionally.
Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE
auth, push/pull/sync actions, and field mapping between pending tasks
and Google Tasks (category↔tasklist, `priority`/`recur` via notes).
* refactor(cli): promote sync backends to top-level subcommands
Problem: `:Pending sync gtasks auth` required an extra `sync` keyword
that added no value and made the command unnecessarily verbose.
Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands
via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend
modules for available actions instead of hardcoding `{ 'auth', 'sync' }`.
* docs(gtasks): document Google Tasks backend and CLI changes
Problem: vimdoc had no coverage for the gtasks backend and still
referenced the old `:Pending sync <backend>` command form.
Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections
with per-action docs, update sync backend interface, and add gtasks
config example.
* ci: format
* refactor(sync): extract shared OAuth into `oauth.lua`
Problem: `gcal.lua` and `gtasks.lua` duplicated ~250 lines of identical
OAuth code (token management, PKCE flow, credential loading, curl
helpers, url encoding).
Solution: Extract a shared `OAuthClient` metatable in `oauth.lua` with
module-level utilities and instance methods. Both backends now delegate
all OAuth to `oauth.new()`. Skip `oauth` in `health.lua` backend
discovery by checking for a `name` field.
* feat(sync): ship bundled OAuth credentials
Problem: Users must manually create a Google Cloud project and place a
credentials JSON file before sync works — terrible onboarding.
Solution: Add `client_id`/`client_secret` fields to `GcalConfig` and
`GtasksConfig`. `oauth.lua` resolves credentials in three tiers: config
fields, credentials file, then bundled defaults (placeholders for now).
* docs(sync): document bundled credentials and config fields
* ci: format