Compare commits

...

26 commits

Author SHA1 Message Date
Barrett Ruth
49038f9308
fix(parse): position-independent inline metadata parsing (#164)
Some checks are pending
quality / changes (push) Waiting to run
quality / Lua Format Check (push) Blocked by required conditions
quality / Lua Lint Check (push) Blocked by required conditions
quality / Lua Type Check (push) Blocked by required conditions
quality / Markdown Format Check (push) Blocked by required conditions
test / Test (Neovim nightly) (push) Waiting to run
test / Test (Neovim stable) (push) Waiting to run
Problem: `parse.body()` scanned tokens right-to-left and broke on the
first non-metadata token, so metadata only worked at the trailing end
of a line. `due:tomorrow Fix the bug` silently failed to parse the
due date.

Solution: Replace the right-to-left `while` loop with a single
left-to-right pass that extracts metadata tokens from any position.
Duplicate metadata tokens are dropped with a `log.warn`. Update docs
and tests accordingly.
2026-03-13 20:48:18 -04:00
e816e6fb7e ci: some fixes
Some checks are pending
quality / changes (push) Waiting to run
quality / Lua Format Check (push) Blocked by required conditions
quality / Lua Lint Check (push) Blocked by required conditions
quality / Lua Type Check (push) Blocked by required conditions
quality / Markdown Format Check (push) Blocked by required conditions
test / Test (Neovim nightly) (push) Waiting to run
test / Test (Neovim stable) (push) Waiting to run
2026-03-13 20:38:29 -04:00
2b75843dab fix: revert dev 2026-03-13 17:58:49 -04:00
Barrett Ruth
f846155ee5
feat(detail): parse and validate editable frontmatter on save (#163)
Problem: the detail buffer rendered metadata as read-only virtual text
overlays. Users could not edit status, priority, category, due, or
recurrence from the detail view.

Solution: render frontmatter as real `Key: value` text lines highlighted
via extmarks. On `:w`, `parse_detail_frontmatter()` validates every
field (status, priority bounds, `resolve_date`, `recur.validate`) and
aborts with `log.error()` on any invalid input. Removing a line clears
the field; editing the `# title` updates the description.
2026-03-13 11:06:45 -04:00
Barrett Ruth
f472ff8990
feat: add markdown detail buffer for task notes (#162)
Problem: tasks only have a one-line description. There is no way to
attach extended notes, checklists, or context to a task.

Solution: add `ge` keymap to open a `pending://task/<id>` markdown
buffer that replaces the task list in the same split. The buffer shows
a read-only metadata header (status, priority, category, due,
recurrence) rendered via extmarks, a `---` separator, and editable
notes below. `:w` saves notes to a new top-level `notes` field on the
task stored in the single `tasks.json`. `q` returns to the task list.
2026-03-13 08:22:04 -04:00
0b0b64fc3d ci: format
Some checks are pending
quality / changes (push) Waiting to run
quality / Lua Format Check (push) Blocked by required conditions
quality / Lua Lint Check (push) Blocked by required conditions
quality / Lua Type Check (push) Blocked by required conditions
quality / Markdown Format Check (push) Blocked by required conditions
test / Test (Neovim nightly) (push) Waiting to run
test / Test (Neovim stable) (push) Waiting to run
2026-03-12 21:01:17 -04:00
Barrett Ruth
c04057dd9f
fix(config): use / as default cancelled icon (#159)
Problem: the cancelled icon defaulted to `c`, inconsistent with the
`g/` keymap. Other statuses match: `gw` → `[w]`, `gb` → `[b]`.

Solution: change `icons.cancelled` default from `c` to `/` so the
keymap and state char are consistent.
2026-03-12 21:01:00 -04:00
Barrett Ruth
7c3ba31c43
feat: add cancelled task status with configurable state chars (#158)
Problem: the task lifecycle only has `pending`, `wip`, `blocked`, and
`done`. There is no way to mark a task as abandoned. Additionally,
state characters (`>`, `=`) are hardcoded rather than reading from
`config.icons`, so customizing them has no effect on rendering or
parsing.

Solution: add a `cancelled` status with default state char `c`, `g/`
keymap, `PendingCancelled` highlight, filter predicate, and archive
support. Unify state chars by making `state_char()`, `parse_buffer()`,
and `infer_status()` read from `config.icons`. Change defaults to
mnemonic chars: `w` (wip), `b` (blocked), `c` (cancelled).
2026-03-12 20:55:21 -04:00
Barrett Ruth
4a37cb64e4
fix(views): pluralize unknown queue sort key warning (#157)
Problem: multiple unknown sort keys each triggered a separate warning.

Solution: collect unknown keys and emit a single warning with the
correct singular/plural label, joined by `, `.
2026-03-12 20:47:04 -04:00
5ab0aa78a1
Revert "feat(views): add hide_done_categories config option (#153)"
This reverts commit 283f93eda1.
2026-03-12 20:41:36 -04:00
b2456580b5
ci: format 2026-03-12 20:29:04 -04:00
Barrett Ruth
969dbd299f
feat(views): make queue view sort order configurable (#154)
Problem: the queue/priority view sort in `sort_tasks_priority()` uses a
hardcoded tiebreak chain (status, priority, due, order, id). Users who
care more about due dates than priority have no way to reorder it.

Solution: add `view.queue.sort` config field (string[]) that defines an
ordered tiebreak chain. `build_queue_comparator()` maps each key to a
comparison function and returns a single comparator. Unknown keys emit a
`log.warn`. The default matches the previous hardcoded behavior.
2026-03-12 20:29:04 -04:00
Barrett Ruth
283f93eda1
feat(views): add hide_done_categories config option (#153)
Problem: Categories where every task is done still render in the buffer,
cluttering the view when entire categories are finished.

Solution: Add `view.category.hide_done_categories` (boolean, default
false). When enabled, `category_view()` skips categories whose tasks are
all done/deleted, returns their IDs as `done_cat_hidden_ids`, and
`_on_write` merges those IDs into `hidden_ids` passed to `diff.apply()`
so hidden tasks are not mistakenly deleted on `:w`.
2026-03-12 20:29:03 -04:00
Barrett Ruth
ea59bbae96
fix(init): preserve cursor column and position in mutation functions (#152)
Problem: `toggle_complete()`, `toggle_priority()`, `adjust_priority()`,
`toggle_status()`, and `move_task()` captured only the row from
`nvim_win_get_cursor` and restored the cursor to column 0 after
re-render. Additionally, `toggle_complete()` followed the toggled task
to its new sorted position at the bottom of the category, which is
disorienting when working through a list of tasks.

Solution: Capture both row and column from the cursor, and restore the
column in all five functions. For `toggle_complete()`, instead of
chasing the task ID after render, clamp the cursor to the original row
(or total lines if shorter) and advance to the nearest task line,
similar to the `]t` motion in `textobj.lua`.
2026-03-12 20:29:03 -04:00
Barrett Ruth
9593ab7fe8
feat(priority): add g<C-a> and g<C-x> visual batch priority mappings (#151)
Problem: Incrementing or decrementing priority required operating on one
task at a time with `<C-a>`/`<C-x>`, which is tedious when adjusting
multiple tasks.

Solution: Add `adjust_priority_visual(delta)` that iterates the visual
selection range, updates every task line's priority in one pass, then
re-renders once. Exposed as `increment_priority_visual()` /
`decrement_priority_visual()` with `g<C-a>` / `g<C-x>` defaults, new
`<Plug>` mappings, and config keys `priority_up_visual` /
`priority_down_visual`.
2026-03-12 20:29:03 -04:00
Barrett Ruth
d35f34d8e0
feat(complete): add metadata completion for :Pending add (#144)
Problem: `:Pending add` had no tab completion for inline metadata
tokens, unlike `:Pending edit` which already completed `due:`, `rec:`,
and `cat:` values.

Solution: Add `complete_add()` that handles `due:`, `rec:`, and `cat:`
prefix matching with the same value sources used by `complete_edit()`,
and wire it into the command completion dispatcher.
2026-03-12 20:29:03 -04:00
Barrett Ruth
c9790ed3bf
fix(parse): skip forge refs in right-to-left metadata scan (#142)
Problem: `parse.body()` scans tokens right-to-left and breaks on the
first non-metadata token. Forge refs like `gl:a/b#12` halted the scan,
preventing metadata tokens to their left (e.g. `due:tomorrow`) from
being parsed. Additionally, `diff.parse_buffer()` ignored
`metadata.priority` from `+!!` tokens and only used checkbox-derived
priority, and priority updates between two non-zero values were silently
skipped.

Solution: Recognize forge ref tokens via `forge.parse_ref()` during the
right-to-left scan and skip past them, re-appending them to the
description so `forge.find_refs()` still works. Prefer
`metadata.priority` over checkbox priority in `parse_buffer()`, and
simplify the priority update condition to catch all value changes.
2026-03-12 20:29:02 -04:00
Barrett Ruth
939251f629
refactor: tighten LuaCATS annotations and canonicalize metadata fields (#141)
* refactor: tighten LuaCATS annotations across modules

Problem: type annotations repeated inline unions with no aliases,
used `table<string, any>` where structured types exist, and had
loose `string` where union types should be used.

Solution: add `pending.TaskStatus`, `pending.RecurMode`,
`pending.TaskExtra`, `pending.ForgeType`, `pending.ForgeState`,
`pending.ForgeAuthStatus` aliases and `pending.SyncBackend`
interface. Replace inline unions and loose types with the new
aliases in `store.lua`, `forge.lua`, `config.lua`, `diff.lua`,
`views.lua`, `parse.lua`, `init.lua`, and `oauth.lua`.

* refactor: canonicalize internal metadata field names

Problem: `pending.Metadata` used shorthand field names (`cat`, `rec`,
`rec_mode`) matching user-facing token syntax, coupling internal
representation to config. `RecurSpec.from_completion` used a boolean
where a `pending.RecurMode` alias exists. `category_syntax` was
hardcoded to `'cat'` with no config option.

Solution: rename `Metadata` fields to `category`/`recur`/`recur_mode`,
add `category_syntax` config option (default `'cat'`), rename
`ParsedEntry` fields to match, replace `RecurSpec.from_completion`
with `mode: pending.RecurMode`, and restore `[string]` indexer on
`pending.ForgeConfig` alongside explicit fields.
2026-03-12 20:29:02 -04:00
Barrett Ruth
46b5d52b60
feat(forge): support bare repo-level forge refs (#135) (#140)
Problem: Forge refs required an issue/PR number (`gh:user/repo#42`).
Users wanting to link a repo without a specific issue had no option.

Solution: Accept `gh:user/repo` shorthand and `https://github.com/user/repo`
URLs as `type='repo'` refs with `number=nil`. These conceal and render
virtual text like numbered refs but skip all API calls (no validate, no
fetch, no close). `format_label` strips `#%n` for bare refs. Omnifunc
offers both `owner/repo#` and `owner/repo` completions.

Closes #135
2026-03-12 20:29:02 -04:00
Barrett Ruth
1064b7535a
refactor(forge): simplify auth gating (#139)
* 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

* 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.

* ci: typing and formatting

* refactor(forge): simplify auth gating and rename `gitea_backend`

Problem: forge auth/warning logic was scattered through
`fetch_metadata` — per-API-call auth status checks, `_warned` flags,
and `warn_missing_cli` conditionals on every fetch.

Solution: replace `_warned` with `_auth` (cached per session), add
`is_configured()` to skip unconfigured forges entirely, extract
`check_auth()` for one-time auth verification, and strip
`fetch_metadata` to a pure API caller returning `ForgeFetchError`.
Gate `refresh` and new `validate_refs` with both checks. Rename
`gitea_backend` to `gitea_forge`.
2026-03-12 20:29:02 -04:00
Barrett Ruth
6f71ab14ad
feat(forge): add validate option for forge ref validation on write (#138)
Problem: Typos in forge refs like `gh:user/repo#42` silently persist —
there's no feedback when a ref points to a nonexistent issue.

Solution: Add `forge.validate` config option. When enabled, `diff.apply()`
returns new/changed `ForgeRef[]` and `forge.validate_refs()` fetches
metadata for each, logging specific warnings for not-found, auth, or
CLI-missing errors.
2026-03-12 20:29:02 -04:00
Barrett Ruth
ff9f601f68
fix(forge): fix ghost extmarks, false auth warnings, and needless API calls (#136)
* 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

* 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.

* ci: typing and formatting
2026-03-12 20:29:02 -04:00
Barrett Ruth
0d62cd9e40
refactor(forge): rename auto_close to close (#137)
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.
2026-03-12 20:29:01 -04:00
Barrett Ruth
343dbb202b
Revert "feat(diff): disallow editing done tasks by default (#132)" (#133)
This reverts commit 24e8741ae1.
2026-03-12 20:29:01 -04:00
Barrett Ruth
26b8bb4beb
feat(forge): support custom shorthand prefixes (#131)
* 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()`.
2026-03-12 20:29:01 -04:00
Barrett Ruth
1bd2ef914b
feat(diff): disallow editing done tasks by default (#132)
* 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.
2026-03-12 20:29:01 -04:00
22 changed files with 2509 additions and 406 deletions

View file

@ -140,9 +140,9 @@ COMMANDS *pending-commands*
:Pending add Work: standup due:tomorrow rec:weekdays :Pending add Work: standup due:tomorrow rec:weekdays
:Pending add Buy milk due:fri +!! :Pending add Buy milk due:fri +!!
< <
Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped `+!`, `+!!`, or `+!!!` tokens anywhere in the text set the priority
at `max_priority`). If the buffer is currently open it is re-rendered level (capped at `max_priority`). If the buffer is currently open it
after the add. is re-rendered after the add.
*:Pending-archive* *:Pending-archive*
:Pending archive [{duration}] :Pending archive [{duration}]
@ -347,6 +347,8 @@ Default buffer-local keys: ~
`gr` Prompt for a recurrence pattern (`recur`) `gr` Prompt for a recurrence pattern (`recur`)
`gw` Toggle work-in-progress status (`wip`) `gw` Toggle work-in-progress status (`wip`)
`gb` Toggle blocked status (`blocked`) `gb` Toggle blocked status (`blocked`)
`g/` Toggle cancelled status (`cancelled`)
`ge` Open markdown detail buffer for task notes (`edit_notes`)
`gf` Prompt for filter predicates (`filter`) `gf` Prompt for filter predicates (`filter`)
`<Tab>` Switch between category / queue view (`view`) `<Tab>` Switch between category / queue view (`view`)
`gz` Undo the last `:w` save (`undo`) `gz` Undo the last `:w` save (`undo`)
@ -354,6 +356,8 @@ Default buffer-local keys: ~
`O` Insert a new task line above (`open_line_above`) `O` Insert a new task line above (`open_line_above`)
`<C-a>` Increment priority (clamped at `max_priority`) (`priority_up`) `<C-a>` Increment priority (clamped at `max_priority`) (`priority_up`)
`<C-x>` Decrement priority (clamped at 0) (`priority_down`) `<C-x>` Decrement priority (clamped at 0) (`priority_down`)
`g<C-a>` Increment priority for visual selection (`priority_up_visual`)
`g<C-x>` Decrement priority for visual selection (`priority_down_visual`)
`J` Move task down within its category (`move_down`) `J` Move task down within its category (`move_down`)
`K` Move task up within its category (`move_up`) `K` Move task up within its category (`move_up`)
`zc` Fold the current category section (requires `folding`) `zc` Fold the current category section (requires `folding`)
@ -468,6 +472,12 @@ old keys to `false`: >lua
Toggle blocked status for the task under the cursor. Toggle blocked status for the task under the cursor.
If the task is already `blocked`, reverts to `pending`. If the task is already `blocked`, reverts to `pending`.
*<Plug>(pending-cancelled)*
<Plug>(pending-cancelled)
Toggle cancelled status for the task under the cursor.
If the task is already `cancelled`, reverts to `pending`.
Toggling on a `done` task switches it to `cancelled`.
*<Plug>(pending-priority-up)* *<Plug>(pending-priority-up)*
<Plug>(pending-priority-up) <Plug>(pending-priority-up)
Increment the priority level for the task under the cursor, clamped Increment the priority level for the task under the cursor, clamped
@ -478,6 +488,12 @@ old keys to `false`: >lua
Decrement the priority level for the task under the cursor, clamped Decrement the priority level for the task under the cursor, clamped
at 0. Default key: `<C-x>`. at 0. Default key: `<C-x>`.
*<Plug>(pending-edit-notes)*
<Plug>(pending-edit-notes)
Open the markdown detail buffer for the task under the cursor.
Shows a read-only metadata header and editable notes below a `---`
separator. Press `q` to return to the task list. Default key: `ge`.
*<Plug>(pending-open-line)* *<Plug>(pending-open-line)*
<Plug>(pending-open-line) <Plug>(pending-open-line)
Insert a correctly-formatted blank task line below the cursor. Insert a correctly-formatted blank task line below the cursor.
@ -535,16 +551,41 @@ Category view (default): ~ *pending-view-category*
Tasks are grouped under their category header. Categories appear in the Tasks are grouped under their category header. Categories appear in the
order tasks were added unless `category_order` is set (see order tasks were added unless `category_order` is set (see
|pending-config|). Blank lines separate categories. Within each category, |pending-config|). Blank lines separate categories. Within each category,
tasks are sorted by status (wip → pending → blocked → done), then by tasks are sorted by status (wip → pending → blocked → done → cancelled), then by
priority, then by insertion order. Category sections are foldable with priority, then by insertion order. Category sections are foldable with
`zc` and `zo`. `zc` and `zo`.
Queue view: ~ *pending-view-queue* Queue view: ~ *pending-view-queue*
A flat list of all tasks sorted by status (wip → pending → blocked → A flat list of all tasks sorted by a configurable tiebreak chain
done), then by priority, then by due date (tasks without a due date sort (default: status → priority → due → order → id). See
last), then by internal order. Category names are shown as right-aligned virtual `view.queue.sort` in |pending-config| for customization. Status
text alongside the due date virtual text so tasks remain identifiable order: wip → pending → blocked → done → cancelled. Category
across categories. The buffer is named `pending://queue`. names are shown as right-aligned virtual text alongside the due date
virtual text so tasks remain identifiable across categories. The
buffer is named `pending://queue`.
==============================================================================
DETAIL BUFFER *pending-detail-buffer*
Press `ge` (or `keymaps.edit_notes`) on a task to open a markdown detail
buffer named `pending://task/<id>`. The buffer replaces the task list in
the same split.
Layout: ~
Line 1: `# <description>` (task description as heading)
Lines 2-3: Read-only metadata (status, priority, category, due,
recurrence) rendered as virtual text overlays
Line 4: `---` separator
Line 5+: Free-form markdown notes (editable)
The metadata header is not editable — it is rendered via extmarks on
empty buffer lines. To change metadata, return to the task list and use
the normal keymaps or `:Pending edit`.
Write (`:w`) saves the notes content (everything below the `---`
separator) to the `notes` field in the task store. Press `q` to return
to the task list.
============================================================================== ==============================================================================
FILTERS *pending-filters* FILTERS *pending-filters*
@ -578,6 +619,8 @@ Available predicates: ~
`blocked` Show only tasks with status `blocked`. `blocked` Show only tasks with status `blocked`.
`cancelled` Show only tasks with status `cancelled`.
`clear` Special value for |:Pending-filter| — clears the active filter `clear` Special value for |:Pending-filter| — clears the active filter
and shows all tasks. and shows all tasks.
@ -595,8 +638,8 @@ task data.
============================================================================== ==============================================================================
INLINE METADATA *pending-metadata* INLINE METADATA *pending-metadata*
Metadata tokens may be appended to any task line before saving. Tokens are Metadata tokens may appear anywhere in a task line. On save, tokens are
parsed from the right and consumed until a non-metadata token is reached. extracted from any position and the remaining words form the description.
Supported tokens: ~ Supported tokens: ~
@ -605,9 +648,10 @@ Supported tokens: ~
`cat:Name` Move the task to the named category on save. `cat:Name` Move the task to the named category on save.
`rec:<pattern>` Set a recurrence rule (see |pending-recurrence|). `rec:<pattern>` Set a recurrence rule (see |pending-recurrence|).
The token name for due dates defaults to `due` and is configurable via The token name for categories defaults to `cat` and is configurable via
`date_syntax` in |pending-config|. The token name for recurrence defaults to `category_syntax` in |pending-config|. The token name for due dates defaults
`rec` and is configurable via `recur_syntax`. to `due` and is configurable via `date_syntax`. The token name for recurrence
defaults to `rec` and is configurable via `recur_syntax`.
Example: > Example: >
@ -619,9 +663,8 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as
`2026-03-15` and rendered as right-aligned virtual text, and the task is `2026-03-15` and rendered as right-aligned virtual text, and the task is
placed under the `Errands` category header. placed under the `Errands` category header.
Parsing stops at the first token that is not a recognised metadata token. Only the first occurrence of each metadata type is consumed — duplicate
Repeated tokens of the same type also stop parsing — only one `due:`, one tokens are dropped with a warning.
`cat:`, and one `rec:` per task line are consumed.
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
In insert mode, type the token prefix and press `<C-x><C-o>` to see In insert mode, type the token prefix and press `<C-x><C-o>` to see
@ -734,6 +777,7 @@ loads: >lua
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_category = 'Todo', default_category = 'Todo',
date_format = '%b %d', date_format = '%b %d',
category_syntax = 'cat',
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec', recur_syntax = 'rec',
someday_date = '9999-12-30', someday_date = '9999-12-30',
@ -745,7 +789,9 @@ loads: >lua
order = {}, order = {},
folding = true, folding = true,
}, },
queue = {}, queue = {
sort = { 'status', 'priority', 'due', 'order', 'id' },
},
}, },
keymaps = { keymaps = {
close = 'q', close = 'q',
@ -771,6 +817,8 @@ loads: >lua
move_up = 'K', move_up = 'K',
wip = 'gw', wip = 'gw',
blocked = 'gb', blocked = 'gb',
cancelled = 'g/',
edit_notes = 'ge',
}, },
sync = { sync = {
gcal = {}, gcal = {},
@ -817,6 +865,12 @@ Fields: ~
'%m/%d', -- 03/15 (year inferred) '%m/%d', -- 03/15 (year inferred)
} }
< <
{category_syntax} (string, default: 'cat')
The token name for inline category metadata. Change
this to use a different keyword, for example
`'category'` to write `category:Work` instead of
`cat:Work`.
{date_syntax} (string, default: 'due') {date_syntax} (string, default: 'due')
The token name for inline due-date metadata. Change The token name for inline due-date metadata. Change
this to use a different keyword, for example `'by'` this to use a different keyword, for example `'by'`
@ -871,6 +925,24 @@ Fields: ~
{queue} (table) *pending.QueueViewConfig* {queue} (table) *pending.QueueViewConfig*
Queue (priority) view settings. Queue (priority) view settings.
{sort} (string[], default:
`{ 'status', 'priority', 'due',
'order', 'id' }`)
Ordered tiebreak chain for the
queue view sort. Each element is a
sort key; the comparator walks the
list and returns on the first
non-equal comparison. Valid keys:
`status` wip < pending <
blocked < done
`priority` higher number first
`due` sooner first, no-due
last
`order` ascending
`id` ascending
`age` alias for `id`
Unknown keys are ignored with a
warning.
Examples: >lua Examples: >lua
vim.g.pending = { vim.g.pending = {
@ -881,6 +953,10 @@ Fields: ~
order = { 'Work', 'Personal' }, order = { 'Work', 'Personal' },
folding = { foldtext = '%c: %n items' }, folding = { foldtext = '%c: %n items' },
}, },
queue = {
sort = { 'status', 'due', 'priority',
'order', 'id' },
},
}, },
} }
< <
@ -922,17 +998,21 @@ Fields: ~
See |pending-gcal|, |pending-gtasks|, |pending-s3|. See |pending-gcal|, |pending-gtasks|, |pending-s3|.
{icons} (table) *pending.Icons* {icons} (table) *pending.Icons*
Icon characters displayed in the buffer. The Icon characters used for rendering and parsing
{pending}, {done}, {priority}, {wip}, and task checkboxes. The {pending}, {done},
{blocked} characters appear inside brackets {priority}, {wip}, {blocked}, and {cancelled}
(`[icon]`) as an overlay on the checkbox. The characters determine what is written inside
{category} character prefixes both header lines brackets (`[icon]`) in the buffer text and how
and EOL category labels. Fields: status is inferred on `:w`. Each must be a
single character. The {category} character
prefixes header lines and EOL category labels.
Fields:
{pending} Pending task character. Default: ' ' {pending} Pending task character. Default: ' '
{done} Done task character. Default: 'x' {done} Done task character. Default: 'x'
{priority} Priority task character. Default: '!' {priority} Priority task character. Default: '!'
{wip} Work-in-progress character. Default: '>' {wip} Work-in-progress character. Default: 'w'
{blocked} Blocked task character. Default: '=' {blocked} Blocked task character. Default: 'b'
{cancelled} Cancelled task character. Default: '/'
{due} Due date prefix. Default: '.' {due} Due date prefix. Default: '.'
{recur} Recurrence prefix. Default: '~' {recur} Recurrence prefix. Default: '~'
{category} Category prefix. Default: '#' {category} Category prefix. Default: '#'
@ -989,6 +1069,10 @@ PendingWip Applied to the checkbox icon of work-in-progress tasks.
PendingBlocked Applied to the checkbox icon and text of blocked tasks. PendingBlocked Applied to the checkbox icon and text of blocked tasks.
Default: links to `DiagnosticError`. Default: links to `DiagnosticError`.
*PendingCancelled*
PendingCancelled Applied to the checkbox icon and text of cancelled tasks.
Default: links to `NonText`.
*PendingPriority* *PendingPriority*
PendingPriority Applied to the checkbox icon of priority 1 tasks. PendingPriority Applied to the checkbox icon of priority 1 tasks.
Default: links to `DiagnosticWarn`. Default: links to `DiagnosticWarn`.
@ -1500,7 +1584,8 @@ Configuration: ~
>lua >lua
vim.g.pending = { vim.g.pending = {
forge = { forge = {
auto_close = false, close = false,
validate = false,
warn_missing_cli = true, warn_missing_cli = true,
github = { github = {
icon = '', icon = '',
@ -1522,9 +1607,15 @@ Configuration: ~
< <
Top-level fields: ~ Top-level fields: ~
{auto_close} (boolean, default: false) When true, tasks linked to {close} (boolean, default: false) When true, tasks linked to
closed/merged remote issues are automatically marked closed/merged remote issues are automatically marked
done on buffer open. done on buffer open. Only forges with an explicit
per-forge key (e.g. `github = {}`) are checked;
unconfigured forges are skipped entirely.
{validate} (boolean, default: false) When true, new or changed
forge refs are validated on `:w` by fetching metadata.
Logs a warning if the ref is not found, auth fails, or
the CLI is missing.
{warn_missing_cli} (boolean, default: true) When true, warns once per {warn_missing_cli} (boolean, default: true) When true, warns once per
forge per session if the CLI is missing or fails. forge per session if the CLI is missing or fails.
@ -1550,9 +1641,9 @@ than 5 minutes are re-fetched asynchronously. The buffer renders immediately
with cached data and updates extmarks when the fetch completes. with cached data and updates extmarks when the fetch completes.
State pull: ~ State pull: ~
Requires `forge.auto_close = true`. After fetching, if the remote issue/PR Requires `forge.close = true`. After fetching, if the remote issue/PR
is closed or merged and the local task is pending/wip/blocked, the task is is closed or merged and the local task is pending/wip/blocked (not cancelled),
automatically marked as done. Disabled by default. One-way: local status the task is automatically marked as done. Disabled by default. One-way: local status
changes do not push back to the forge. changes do not push back to the forge.
Highlight groups: ~ Highlight groups: ~
@ -1580,7 +1671,7 @@ Task fields: ~
{id} (integer) Unique, auto-incrementing task identifier. {id} (integer) Unique, auto-incrementing task identifier.
{description} (string) Task text as shown in the buffer. {description} (string) Task text as shown in the buffer.
{status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`,
or `'deleted'`. `'cancelled'`, or `'deleted'`.
{category} (string) Category name. Defaults to `default_category`. {category} (string) Category name. Defaults to `default_category`.
{priority} (integer) Priority level: `0` (none), `1``3` (or up to {priority} (integer) Priority level: `0` (none), `1``3` (or up to
`max_priority`). Higher values sort first. `max_priority`). Higher values sort first.
@ -1590,6 +1681,7 @@ Task fields: ~
{entry} (string) ISO 8601 UTC timestamp of creation. {entry} (string) ISO 8601 UTC timestamp of creation.
{modified} (string) ISO 8601 UTC timestamp of last modification. {modified} (string) ISO 8601 UTC timestamp of last modification.
{end} (string) ISO 8601 UTC timestamp of completion or deletion. {end} (string) ISO 8601 UTC timestamp of completion or deletion.
{notes} (string) Free-form markdown notes (from detail buffer).
{order} (integer) Relative ordering within a category. {order} (integer) Relative ordering within a category.
Any field not in the list above is preserved in `_extra` and written back on Any field not in the list above is preserved in `_extra` and written back on

View file

@ -128,6 +128,7 @@ local function apply_inline_row(bufnr, row, m, icons)
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
end_col = #line, end_col = #line,
hl_group = 'PendingFilter', hl_group = 'PendingFilter',
invalidate = true,
}) })
elseif m.type == 'task' then elseif m.type == 'task' then
if m.status == 'done' then if m.status == 'done' then
@ -136,6 +137,15 @@ local function apply_inline_row(bufnr, row, m, icons)
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
end_col = #line, end_col = #line,
hl_group = 'PendingDone', hl_group = 'PendingDone',
invalidate = true,
})
elseif m.status == 'cancelled' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
end_col = #line,
hl_group = 'PendingCancelled',
invalidate = true,
}) })
elseif m.status == 'blocked' then elseif m.status == 'blocked' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
@ -143,6 +153,7 @@ local function apply_inline_row(bufnr, row, m, icons)
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
end_col = #line, end_col = #line,
hl_group = 'PendingBlocked', hl_group = 'PendingBlocked',
invalidate = true,
}) })
end end
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
@ -150,10 +161,12 @@ local function apply_inline_row(bufnr, row, m, icons)
local icon, icon_hl local icon, icon_hl
if m.status == 'done' then if m.status == 'done' then
icon, icon_hl = icons.done, 'PendingDone' icon, icon_hl = icons.done, 'PendingDone'
elseif m.status == 'cancelled' then
icon, icon_hl = icons.cancelled, 'PendingCancelled'
elseif m.status == 'wip' then elseif m.status == 'wip' then
icon, icon_hl = icons.wip or '>', 'PendingWip' icon, icon_hl = icons.wip, 'PendingWip'
elseif m.status == 'blocked' then elseif m.status == 'blocked' then
icon, icon_hl = icons.blocked or '=', 'PendingBlocked' icon, icon_hl = icons.blocked, 'PendingBlocked'
elseif m.priority and m.priority >= 3 then elseif m.priority and m.priority >= 3 then
icon, icon_hl = icons.priority, 'PendingPriority3' icon, icon_hl = icons.priority, 'PendingPriority3'
elseif m.priority and m.priority == 2 then elseif m.priority and m.priority == 2 then
@ -167,6 +180,7 @@ local function apply_inline_row(bufnr, row, m, icons)
virt_text = { { '[' .. icon .. ']', icon_hl } }, virt_text = { { '[' .. icon .. ']', icon_hl } },
virt_text_pos = 'overlay', virt_text_pos = 'overlay',
priority = 100, priority = 100,
invalidate = true,
}) })
if m.forge_spans then if m.forge_spans then
local forge = require('pending.forge') local forge = require('pending.forge')
@ -178,6 +192,7 @@ local function apply_inline_row(bufnr, row, m, icons)
virt_text = { { label_text, hl_group } }, virt_text = { { label_text, hl_group } },
virt_text_pos = 'inline', virt_text_pos = 'inline',
priority = 90, priority = 90,
invalidate = true,
}) })
end end
end end
@ -186,11 +201,13 @@ local function apply_inline_row(bufnr, row, m, icons)
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
end_col = #line, end_col = #line,
hl_group = 'PendingHeader', hl_group = 'PendingHeader',
invalidate = true,
}) })
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
virt_text = { { icons.category .. ' ', 'PendingHeader' } }, virt_text = { { icons.category .. ' ', 'PendingHeader' } },
virt_text_pos = 'overlay', virt_text_pos = 'overlay',
priority = 100, priority = 100,
invalidate = true,
}) })
end end
end end
@ -202,11 +219,14 @@ local function infer_status(line)
if not ch then if not ch then
return nil return nil
end end
if ch == 'x' then local icons = config.get().icons
if ch == icons.done then
return 'done' return 'done'
elseif ch == '>' then elseif ch == icons.cancelled then
return 'cancelled'
elseif ch == icons.wip then
return 'wip' return 'wip'
elseif ch == '=' then elseif ch == icons.blocked then
return 'blocked' return 'blocked'
end end
return 'pending' return 'pending'
@ -541,6 +561,7 @@ local function apply_extmarks(bufnr, line_meta)
vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, {
virt_text = virt_parts, virt_text = virt_parts,
virt_text_pos = 'eol', virt_text_pos = 'eol',
invalidate = true,
}) })
end end
end end
@ -558,10 +579,12 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingCancelled', { link = 'NonText', default = true })
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true })
end end
---@return string ---@return string
@ -783,4 +806,329 @@ function M.open()
return task_bufnr return task_bufnr
end end
local ns_detail = vim.api.nvim_create_namespace('pending_detail')
local DETAIL_SEPARATOR = '---'
---@type integer?
local _detail_bufnr = nil
---@type integer?
local _detail_task_id = nil
---@return integer?
function M.detail_bufnr()
return _detail_bufnr
end
---@return integer?
function M.detail_task_id()
return _detail_task_id
end
local VALID_STATUSES = {
pending = true,
done = true,
wip = true,
blocked = true,
cancelled = true,
}
---@param task pending.Task
---@return string[]
local function build_detail_frontmatter(task)
local lines = {}
table.insert(lines, 'Status: ' .. (task.status or 'pending'))
table.insert(lines, 'Priority: ' .. (task.priority or 0))
if task.category then
table.insert(lines, 'Category: ' .. task.category)
end
if task.due then
table.insert(lines, 'Due: ' .. task.due)
end
if task.recur then
local recur_val = task.recur
if task.recur_mode == 'completion' then
recur_val = '!' .. recur_val
end
table.insert(lines, 'Recur: ' .. recur_val)
end
return lines
end
---@param bufnr integer
---@param sep_row integer
---@return nil
local function apply_detail_extmarks(bufnr, sep_row)
vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
for i = 1, sep_row - 1 do
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, i, 0, {
end_row = i,
end_col = #(vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ''),
hl_group = 'PendingDetailMeta',
})
end
end
---@param task_id integer
---@return integer? bufnr
function M.open_detail(task_id)
if not _store then
return nil
end
if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then
if _detail_task_id == task_id then
return _detail_bufnr
end
vim.api.nvim_buf_delete(_detail_bufnr, { force = true })
_detail_bufnr = nil
_detail_task_id = nil
end
local task = _store:get(task_id)
if not task then
log.warn('task not found: ' .. task_id)
return nil
end
setup_highlights()
local bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_buf_set_name(bufnr, 'pending://task/' .. task_id)
vim.bo[bufnr].buftype = 'acwrite'
vim.bo[bufnr].filetype = 'markdown'
vim.bo[bufnr].swapfile = false
local lines = { '# ' .. task.description }
local fm = build_detail_frontmatter(task)
for _, fl in ipairs(fm) do
table.insert(lines, fl)
end
table.insert(lines, DETAIL_SEPARATOR)
local notes = task.notes or ''
if notes ~= '' then
for note_line in (notes .. '\n'):gmatch('(.-)\n') do
table.insert(lines, note_line)
end
else
table.insert(lines, '')
end
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modified = false
local sep_row = #fm + 1
apply_detail_extmarks(bufnr, sep_row)
local winid = task_winid
if winid and vim.api.nvim_win_is_valid(winid) then
vim.api.nvim_win_set_buf(winid, bufnr)
end
vim.wo[winid].conceallevel = 0
vim.wo[winid].foldmethod = 'manual'
vim.wo[winid].foldenable = false
_detail_bufnr = bufnr
_detail_task_id = task_id
local cursor_row = sep_row + 2
local total = vim.api.nvim_buf_line_count(bufnr)
if cursor_row > total then
cursor_row = total
end
pcall(vim.api.nvim_win_set_cursor, winid, { cursor_row, 0 })
return bufnr
end
---@return nil
function M.close_detail()
if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then
vim.api.nvim_buf_delete(_detail_bufnr, { force = true })
end
_detail_bufnr = nil
_detail_task_id = nil
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then
vim.api.nvim_win_set_buf(task_winid, task_bufnr)
set_win_options(task_winid)
M.render(task_bufnr)
end
end
end
---@param lines string[]
---@return integer? sep_row
---@return pending.DetailFields? fields
---@return string? err
local function parse_detail_frontmatter(lines)
local parse = require('pending.parse')
local recur = require('pending.recur')
local cfg = config.get()
local sep_row = nil
for i, line in ipairs(lines) do
if line == DETAIL_SEPARATOR then
sep_row = i
break
end
end
if not sep_row then
return nil, nil, 'missing separator (---)'
end
local desc = lines[1] and lines[1]:match('^# (.+)$')
if not desc or desc:match('^%s*$') then
return nil, nil, 'missing or empty title (first line must be # <title>)'
end
---@class pending.DetailFields
---@field description string
---@field status pending.TaskStatus
---@field priority integer
---@field category? string|userdata
---@field due? string|userdata
---@field recur? string|userdata
---@field recur_mode? pending.RecurMode|userdata
local fields = {
description = desc,
status = 'pending',
priority = 0,
category = vim.NIL,
due = vim.NIL,
recur = vim.NIL,
recur_mode = vim.NIL,
}
local seen = {} ---@type table<string, boolean>
for i = 2, sep_row - 1 do
local line = lines[i]
if line:match('^%s*$') then
goto continue
end
local key, val = line:match('^(%S+):%s*(.*)$')
if not key then
return nil, nil, 'invalid frontmatter line: ' .. line
end
key = key:lower()
if seen[key] then
return nil, nil, 'duplicate field: ' .. key
end
seen[key] = true
if key == 'status' then
val = val:lower()
if not VALID_STATUSES[val] then
return nil, nil, 'invalid status: ' .. val
end
fields.status = val --[[@as pending.TaskStatus]]
elseif key == 'priority' then
local n = tonumber(val)
if not n or n ~= math.floor(n) or n < 0 then
return nil, nil, 'invalid priority: ' .. val .. ' (must be integer >= 0)'
end
local max = cfg.max_priority or 3
if n > max then
return nil, nil, 'invalid priority: ' .. val .. ' (max is ' .. max .. ')'
end
fields.priority = n --[[@as integer]]
elseif key == 'category' then
if val == '' then
return nil, nil, 'empty category value'
end
fields.category = val
elseif key == 'due' then
if val == '' then
return nil, nil, 'empty due value (remove the line to clear)'
end
local resolved = parse.resolve_date(val)
if resolved then
fields.due = resolved
elseif
val:match('^%d%d%d%d%-%d%d%-%d%d$') or val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
fields.due = val
else
return nil, nil, 'invalid due date: ' .. val
end
elseif key == 'recur' then
if val == '' then
return nil, nil, 'empty recur value (remove the line to clear)'
end
local raw_spec = val
local rec_mode = nil
if raw_spec:sub(1, 1) == '!' then
rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
return nil, nil, 'invalid recurrence: ' .. val
end
fields.recur = raw_spec
fields.recur_mode = rec_mode or vim.NIL
else
return nil, nil, 'unknown field: ' .. key
end
::continue::
end
return sep_row, fields, nil
end
---@return nil
function M.save_detail()
if not _detail_bufnr or not _detail_task_id or not _store then
return
end
local task = _store:get(_detail_task_id)
if not task then
log.warn('task was deleted')
M.close_detail()
return
end
local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false)
local sep_row, fields, err = parse_detail_frontmatter(lines)
if err then
log.error(err)
return
end
---@cast sep_row integer
---@cast fields pending.DetailFields
local notes_text = ''
if sep_row < #lines then
local note_lines = {}
for i = sep_row + 1, #lines do
table.insert(note_lines, lines[i])
end
notes_text = table.concat(note_lines, '\n')
notes_text = notes_text:gsub('%s+$', '')
end
local update = {
description = fields.description,
status = fields.status,
priority = fields.priority,
category = fields.category,
due = fields.due,
recur = fields.recur,
recur_mode = fields.recur_mode,
}
if notes_text == '' then
update.notes = vim.NIL
else
update.notes = notes_text
end
_store:update(_detail_task_id, update)
_store:save()
vim.bo[_detail_bufnr].modified = false
apply_detail_extmarks(_detail_bufnr, sep_row - 1)
end
M._parse_detail_frontmatter = parse_detail_frontmatter
M._build_detail_frontmatter = build_detail_frontmatter
return M return M

View file

@ -1,4 +1,5 @@
local config = require('pending.config') local config = require('pending.config')
local forge = require('pending.forge')
---@class pending.CompletionItem ---@class pending.CompletionItem
---@field word string ---@field word string
@ -109,6 +110,17 @@ local function recur_completions()
return result return result
end end
---@param source string
---@return boolean
function M._is_forge_source(source)
for _, b in ipairs(forge.backends()) do
if b.shorthand == source then
return true
end
end
return false
end
---@type string? ---@type string?
local _complete_source = nil local _complete_source = nil
@ -124,14 +136,16 @@ function M.omnifunc(findstart, base)
local dk = date_key() local dk = date_key()
local rk = recur_key() local rk = recur_key()
local ck = config.get().category_syntax or 'cat'
local checks = { local checks = {
{ vim.pesc(dk) .. ':([%S]*)$', dk }, { vim.pesc(dk) .. ':([%S]*)$', dk },
{ 'cat:([%S]*)$', 'cat' }, { vim.pesc(ck) .. ':([%S]*)$', ck },
{ vim.pesc(rk) .. ':([%S]*)$', rk }, { vim.pesc(rk) .. ':([%S]*)$', rk },
{ 'gh:([%S]*)$', 'gh' },
{ 'gl:([%S]*)$', 'gl' },
{ 'cb:([%S]*)$', 'cb' },
} }
for _, b in ipairs(forge.backends()) do
table.insert(checks, { vim.pesc(b.shorthand) .. ':([%S]*)$', b.shorthand })
end
for _, check in ipairs(checks) do for _, check in ipairs(checks) do
local start = before:find(check[1]) local start = before:find(check[1])
@ -160,10 +174,10 @@ function M.omnifunc(findstart, base)
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
end end
end end
elseif source == 'cat' then elseif source == (config.get().category_syntax or 'cat') then
for _, c in ipairs(get_categories()) do for _, c in ipairs(get_categories()) do
if base == '' or c:sub(1, #base) == base then if base == '' or c:sub(1, #base) == base then
table.insert(matches, { word = c, menu = '[cat]' }) table.insert(matches, { word = c, menu = '[' .. source .. ']' })
end end
end end
elseif source == rk then elseif source == rk then
@ -172,19 +186,25 @@ function M.omnifunc(findstart, base)
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
end end
end end
elseif source == 'gh' or source == 'gl' or source == 'cb' then elseif M._is_forge_source(source) then
local s = require('pending.buffer').store() local s = require('pending.buffer').store()
if s then if s then
local seen = {} local seen = {}
for _, task in ipairs(s:tasks()) do for _, task in ipairs(s:tasks()) do
if task._extra and task._extra._forge_ref then if task._extra and task._extra._forge_ref then
local ref = task._extra._forge_ref local ref = task._extra._forge_ref --[[@as pending.ForgeRef]]
local key = ref.owner .. '/' .. ref.repo local key = ref.owner .. '/' .. ref.repo
if not seen[key] then if not seen[key] then
seen[key] = true seen[key] = true
local word = key .. '#' local word_num = key .. '#'
if base == '' or word:sub(1, #base) == base then if base == '' or word_num:sub(1, #base) == base then
table.insert(matches, { word = word, menu = '[' .. source .. ']' }) table.insert(matches, { word = word_num, menu = '[' .. source .. ']' })
end
if base == '' or key:sub(1, #base) == base then
table.insert(
matches,
{ word = key, menu = '[' .. source .. ']', info = 'Bare repo link' }
)
end end
end end
end end

View file

@ -11,6 +11,7 @@
---@field priority string ---@field priority string
---@field wip string ---@field wip string
---@field blocked string ---@field blocked string
---@field cancelled string
---@field due string ---@field due string
---@field recur string ---@field recur string
---@field category string ---@field category string
@ -37,10 +38,15 @@
---@field icon? string ---@field icon? string
---@field issue_format? string ---@field issue_format? string
---@field instances? string[] ---@field instances? string[]
---@field shorthand? string
---@class pending.ForgeConfig ---@class pending.ForgeConfig
---@field auto_close? boolean ---@field close? boolean
---@field validate? boolean
---@field warn_missing_cli? boolean ---@field warn_missing_cli? boolean
---@field github? pending.ForgeInstanceConfig
---@field gitlab? pending.ForgeInstanceConfig
---@field codeberg? pending.ForgeInstanceConfig
---@field [string] pending.ForgeInstanceConfig ---@field [string] pending.ForgeInstanceConfig
---@class pending.SyncConfig ---@class pending.SyncConfig
@ -73,12 +79,17 @@
---@field move_up? string|false ---@field move_up? string|false
---@field wip? string|false ---@field wip? string|false
---@field blocked? string|false ---@field blocked? string|false
---@field priority_up_visual? string|false
---@field priority_down_visual? string|false
---@field cancelled? string|false
---@field edit_notes? string|false
---@class pending.CategoryViewConfig ---@class pending.CategoryViewConfig
---@field order? string[] ---@field order? string[]
---@field folding? boolean|pending.FoldingConfig ---@field folding? boolean|pending.FoldingConfig
---@class pending.QueueViewConfig ---@class pending.QueueViewConfig
---@field sort? string[]
---@class pending.ViewConfig ---@class pending.ViewConfig
---@field default? 'category'|'priority' ---@field default? 'category'|'priority'
@ -90,6 +101,7 @@
---@field data_path string ---@field data_path string
---@field default_category string ---@field default_category string
---@field date_format string ---@field date_format string
---@field category_syntax string
---@field date_syntax string ---@field date_syntax string
---@field recur_syntax string ---@field recur_syntax string
---@field someday_date string ---@field someday_date string
@ -111,6 +123,7 @@ local defaults = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_category = 'Todo', default_category = 'Todo',
date_format = '%b %d', date_format = '%b %d',
category_syntax = 'cat',
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec', recur_syntax = 'rec',
someday_date = '9999-12-30', someday_date = '9999-12-30',
@ -122,7 +135,9 @@ local defaults = {
order = {}, order = {},
folding = true, folding = true,
}, },
queue = {}, queue = {
sort = { 'status', 'priority', 'due', 'order', 'id' },
},
}, },
keymaps = { keymaps = {
close = 'q', close = 'q',
@ -148,12 +163,17 @@ local defaults = {
move_up = 'K', move_up = 'K',
wip = 'gw', wip = 'gw',
blocked = 'gb', blocked = 'gb',
cancelled = 'g/',
edit_notes = 'ge',
priority_up = '<C-a>', priority_up = '<C-a>',
priority_down = '<C-x>', priority_down = '<C-x>',
priority_up_visual = 'g<C-a>',
priority_down_visual = 'g<C-x>',
}, },
sync = {}, sync = {},
forge = { forge = {
auto_close = false, close = false,
validate = false,
warn_missing_cli = true, warn_missing_cli = true,
github = { github = {
icon = '', icon = '',
@ -175,8 +195,9 @@ local defaults = {
pending = ' ', pending = ' ',
done = 'x', done = 'x',
priority = '!', priority = '!',
wip = '>', wip = 'w',
blocked = '=', blocked = 'b',
cancelled = '/',
due = '.', due = '.',
recur = '~', recur = '~',
category = '#', category = '#',

View file

@ -7,11 +7,11 @@ local parse = require('pending.parse')
---@field id? integer ---@field id? integer
---@field description? string ---@field description? string
---@field priority? integer ---@field priority? integer
---@field status? string ---@field status? pending.TaskStatus
---@field category? string ---@field category? string
---@field due? string ---@field due? string
---@field rec? string ---@field recur? string
---@field rec_mode? string ---@field recur_mode? pending.RecurMode
---@field forge_ref? pending.ForgeRef ---@field forge_ref? pending.ForgeRef
---@field lnum integer ---@field lnum integer
@ -43,14 +43,17 @@ function M.parse_buffer(lines)
table.insert(result, { type = 'blank', lnum = i }) table.insert(result, { type = 'blank', lnum = i })
elseif id or body then elseif id or body then
local stripped = body:match('^- %[.?%] (.*)$') or body local stripped = body:match('^- %[.?%] (.*)$') or body
local state_char = body:match('^- %[(.-)%]') or ' ' local icons = config.get().icons
local priority = state_char == '!' and 1 or 0 local state_char = body:match('^- %[(.-)%]') or icons.pending
local priority = state_char == icons.priority and 1 or 0
local status local status
if state_char == 'x' then if state_char == icons.done then
status = 'done' status = 'done'
elseif state_char == '>' then elseif state_char == icons.cancelled then
status = 'cancelled'
elseif state_char == icons.wip then
status = 'wip' status = 'wip'
elseif state_char == '=' then elseif state_char == icons.blocked then
status = 'blocked' status = 'blocked'
else else
status = 'pending' status = 'pending'
@ -63,12 +66,12 @@ function M.parse_buffer(lines)
type = 'task', type = 'task',
id = id and tonumber(id) or nil, id = id and tonumber(id) or nil,
description = description, description = description,
priority = priority, priority = metadata.priority or priority,
status = status, status = status,
category = metadata.cat or current_category or config.get().default_category, category = metadata.category or current_category or config.get().default_category,
due = metadata.due, due = metadata.due,
rec = metadata.rec, recur = metadata.recur,
rec_mode = metadata.rec_mode, recur_mode = metadata.recur_mode,
forge_ref = forge_ref, forge_ref = forge_ref,
lnum = i, lnum = i,
}) })
@ -82,14 +85,25 @@ function M.parse_buffer(lines)
return result return result
end end
---@param a? pending.ForgeRef
---@param b? pending.ForgeRef
---@return boolean
local function refs_equal(a, b)
if not a or not b then
return false
end
return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number
end
---@param lines string[] ---@param lines string[]
---@param s pending.Store ---@param s pending.Store
---@param hidden_ids? table<integer, true> ---@param hidden_ids? table<integer, true>
---@return nil ---@return pending.ForgeRef[]
function M.apply(lines, s, hidden_ids) function M.apply(lines, s, hidden_ids)
local parsed = M.parse_buffer(lines) local parsed = M.parse_buffer(lines)
local now = timestamp() local now = timestamp()
local data = s:data() local data = s:data()
local new_refs = {} ---@type pending.ForgeRef[]
local old_by_id = {} local old_by_id = {}
for _, task in ipairs(data.tasks) do for _, task in ipairs(data.tasks) do
@ -115,11 +129,14 @@ function M.apply(lines, s, hidden_ids)
category = entry.category, category = entry.category,
priority = entry.priority, priority = entry.priority,
due = entry.due, due = entry.due,
recur = entry.rec, recur = entry.recur,
recur_mode = entry.rec_mode, recur_mode = entry.recur_mode,
order = order_counter, order = order_counter,
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
}) })
if entry.forge_ref then
table.insert(new_refs, entry.forge_ref)
end
else else
seen_ids[entry.id] = true seen_ids[entry.id] = true
local task = old_by_id[entry.id] local task = old_by_id[entry.id]
@ -132,10 +149,7 @@ function M.apply(lines, s, hidden_ids)
task.category = entry.category task.category = entry.category
changed = true changed = true
end end
if entry.priority == 0 and task.priority > 0 then if entry.priority ~= task.priority then
task.priority = 0
changed = true
elseif entry.priority > 0 and task.priority == 0 then
task.priority = entry.priority task.priority = entry.priority
changed = true changed = true
end end
@ -143,17 +157,21 @@ function M.apply(lines, s, hidden_ids)
task.due = entry.due task.due = entry.due
changed = true changed = true
end end
if entry.rec ~= nil then if entry.recur ~= nil then
if task.recur ~= entry.rec then if task.recur ~= entry.recur then
task.recur = entry.rec task.recur = entry.recur
changed = true changed = true
end end
if task.recur_mode ~= entry.rec_mode then if task.recur_mode ~= entry.recur_mode then
task.recur_mode = entry.rec_mode task.recur_mode = entry.recur_mode
changed = true changed = true
end end
end end
if entry.forge_ref ~= nil then if entry.forge_ref ~= nil then
local old_ref = task._extra and task._extra._forge_ref or nil
if not refs_equal(old_ref, entry.forge_ref) then
table.insert(new_refs, entry.forge_ref)
end
if not task._extra then if not task._extra then
task._extra = {} task._extra = {}
end end
@ -162,7 +180,7 @@ function M.apply(lines, s, hidden_ids)
end end
if entry.status and task.status ~= entry.status then if entry.status and task.status ~= entry.status then
task.status = entry.status task.status = entry.status
if entry.status == 'done' then if entry.status == 'done' or entry.status == 'cancelled' then
task['end'] = now task['end'] = now
else else
task['end'] = nil task['end'] = nil
@ -183,11 +201,14 @@ function M.apply(lines, s, hidden_ids)
category = entry.category, category = entry.category,
priority = entry.priority, priority = entry.priority,
due = entry.due, due = entry.due,
recur = entry.rec, recur = entry.recur,
recur_mode = entry.rec_mode, recur_mode = entry.recur_mode,
order = order_counter, order = order_counter,
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
}) })
if entry.forge_ref then
table.insert(new_refs, entry.forge_ref)
end
end end
::continue:: ::continue::
@ -202,6 +223,7 @@ function M.apply(lines, s, hidden_ids)
end end
s:save() s:save()
return new_refs
end end
return M return M

View file

@ -1,32 +1,40 @@
local config = require('pending.config') local config = require('pending.config')
local log = require('pending.log') local log = require('pending.log')
---@alias pending.ForgeType 'issue'|'pull_request'|'merge_request'|'repo'
---@alias pending.ForgeState 'open'|'closed'|'merged'
---@alias pending.ForgeAuthStatus 'unknown'|'ok'|'failed'
---@class pending.ForgeRef ---@class pending.ForgeRef
---@field forge string ---@field forge string
---@field owner string ---@field owner string
---@field repo string ---@field repo string
---@field type 'issue'|'pull_request'|'merge_request' ---@field type pending.ForgeType
---@field number integer ---@field number? integer
---@field url string ---@field url string
---@class pending.ForgeCache ---@class pending.ForgeCache
---@field title? string ---@field title? string
---@field state 'open'|'closed'|'merged' ---@field state pending.ForgeState
---@field labels? string[] ---@field labels? string[]
---@field fetched_at string ---@field fetched_at string
---@class pending.ForgeFetchError
---@field kind 'not_found'|'auth'|'network'
---@class pending.ForgeBackend ---@class pending.ForgeBackend
---@field name string ---@field name string
---@field shorthand string ---@field shorthand string
---@field default_host string ---@field default_host string
---@field cli string ---@field cli string
---@field auth_cmd string ---@field auth_cmd string
---@field auth_status_args string[]
---@field default_icon string ---@field default_icon string
---@field default_issue_format string ---@field default_issue_format string
---@field _warned boolean ---@field _auth? pending.ForgeAuthStatus
---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef?
---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[]
---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' ---@field parse_state fun(self: pending.ForgeBackend, decoded: table): pending.ForgeState
---@class pending.forge ---@class pending.forge
local M = {} local M = {}
@ -49,7 +57,7 @@ local _instances_resolved = false
---@param backend pending.ForgeBackend ---@param backend pending.ForgeBackend
---@return nil ---@return nil
function M.register(backend) function M.register(backend)
backend._warned = false backend._auth = 'unknown'
table.insert(_backends, backend) table.insert(_backends, backend)
_by_name[backend.name] = backend _by_name[backend.name] = backend
_by_shorthand[backend.shorthand] = backend _by_shorthand[backend.shorthand] = backend
@ -62,6 +70,61 @@ function M.backends()
return _backends return _backends
end end
---@param forge_name string
---@return boolean
function M.is_configured(forge_name)
local raw = vim.g.pending
if not raw or not raw.forge then
return false
end
return raw.forge[forge_name] ~= nil
end
---@param backend pending.ForgeBackend
---@param callback fun(ok: boolean)
function M.check_auth(backend, callback)
if backend._auth == 'ok' then
callback(true)
return
end
if backend._auth == 'failed' then
callback(false)
return
end
if vim.fn.executable(backend.cli) == 0 then
backend._auth = 'failed'
local forge_cfg = config.get().forge or {}
if forge_cfg.warn_missing_cli ~= false then
log.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd))
end
callback(false)
return
end
vim.system(backend.auth_status_args, { text = true }, function(result)
vim.schedule(function()
if result.code == 0 then
backend._auth = 'ok'
callback(true)
else
backend._auth = 'failed'
local forge_cfg = config.get().forge or {}
if forge_cfg.warn_missing_cli ~= false then
log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd))
end
callback(false)
end
end)
end)
end
function M._reset_instances()
_instances_resolved = false
_by_shorthand = {}
for _, b in ipairs(_backends) do
_by_shorthand[b.shorthand] = b
end
end
local function _ensure_instances() local function _ensure_instances()
if _instances_resolved then if _instances_resolved then
return return
@ -73,33 +136,60 @@ local function _ensure_instances()
for _, inst in ipairs(forge_cfg.instances or {}) do for _, inst in ipairs(forge_cfg.instances or {}) do
_by_host[inst] = backend _by_host[inst] = backend
end end
if forge_cfg.shorthand and forge_cfg.shorthand ~= backend.shorthand then
_by_shorthand[backend.shorthand] = nil
backend.shorthand = forge_cfg.shorthand
_by_shorthand[backend.shorthand] = backend
end
end end
end end
---@param token string ---@param token string
---@return pending.ForgeRef? ---@return pending.ForgeRef?
function M._parse_shorthand(token) function M._parse_shorthand(token)
local prefix, rest = token:match('^(%l%l):(.+)$') _ensure_instances()
if not prefix then local backend, rest
return nil for prefix, b in pairs(_by_shorthand) do
local candidate = token:match('^' .. vim.pesc(prefix) .. ':(.+)$')
if candidate then
backend = b
rest = candidate
break
end
end end
local backend = _by_shorthand[prefix]
if not backend then if not backend then
return nil return nil
end end
local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$') local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$')
if owner then
local num = tonumber(number) --[[@as integer]]
local url = 'https://'
.. backend.default_host
.. '/'
.. owner
.. '/'
.. repo
.. '/issues/'
.. num
return {
forge = backend.name,
owner = owner,
repo = repo,
type = 'issue',
number = num,
url = url,
}
end
owner, repo = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)$')
if not owner then if not owner then
return nil return nil
end end
local num = tonumber(number) --[[@as integer]]
local url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num
return { return {
forge = backend.name, forge = backend.name,
owner = owner, owner = owner,
repo = repo, repo = repo,
type = 'issue', type = 'repo',
number = num, url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo,
url = url,
} }
end end
@ -192,7 +282,7 @@ end
---@return string[] ---@return string[]
function M._api_args(ref) function M._api_args(ref)
local backend = _by_name[ref.forge] local backend = _by_name[ref.forge]
if not backend then if not backend or not ref.number then
return {} return {}
end end
return backend:api_args(ref) return backend:api_args(ref)
@ -209,12 +299,15 @@ function M.format_label(ref, cache)
local default_icon = backend and backend.default_icon or '' local default_icon = backend and backend.default_icon or ''
local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n' local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n'
local fmt = forge_cfg.issue_format or default_fmt local fmt = forge_cfg.issue_format or default_fmt
if ref.type == 'repo' then
fmt = fmt:gsub('#?%%n', ''):gsub('%s+$', '')
end
local icon = forge_cfg.icon or default_icon local icon = forge_cfg.icon or default_icon
local text = fmt local text = fmt
:gsub('%%i', icon) :gsub('%%i', icon)
:gsub('%%o', ref.owner) :gsub('%%o', ref.owner)
:gsub('%%r', ref.repo) :gsub('%%r', ref.repo)
:gsub('%%n', tostring(ref.number)) :gsub('%%n', ref.number and tostring(ref.number) or '')
local hl = 'PendingForge' local hl = 'PendingForge'
if cache then if cache then
if cache.state == 'closed' or cache.state == 'merged' then if cache.state == 'closed' or cache.state == 'merged' then
@ -225,29 +318,31 @@ function M.format_label(ref, cache)
end end
---@param ref pending.ForgeRef ---@param ref pending.ForgeRef
---@param callback fun(cache: pending.ForgeCache?) ---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?)
function M.fetch_metadata(ref, callback) function M.fetch_metadata(ref, callback)
if ref.type == 'repo' then
callback(nil)
return
end
local args = M._api_args(ref) local args = M._api_args(ref)
vim.system(args, { text = true }, function(result) vim.system(args, { text = true }, function(result)
if result.code ~= 0 or not result.stdout or result.stdout == '' then if result.code ~= 0 or not result.stdout or result.stdout == '' then
local kind = 'network'
local stderr = result.stderr or ''
if stderr:find('404') or stderr:find('Not Found') then
kind = 'not_found'
elseif stderr:find('401') or stderr:find('403') or stderr:find('auth') then
kind = 'auth'
end
vim.schedule(function() vim.schedule(function()
local forge_cfg = config.get().forge or {} callback(nil, { kind = kind })
local backend = _by_name[ref.forge]
if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then
backend._warned = true
log.warn(
('%s not found or not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)
)
end
callback(nil)
end) end)
return return
end end
local ok, decoded = pcall(vim.json.decode, result.stdout) local ok, decoded = pcall(vim.json.decode, result.stdout)
if not ok or not decoded then if not ok or not decoded then
vim.schedule(function() vim.schedule(function()
callback(nil) callback(nil, { kind = 'network' })
end) end)
return return
end end
@ -277,92 +372,155 @@ end
---@param s pending.Store ---@param s pending.Store
function M.refresh(s) function M.refresh(s)
local forge_cfg = config.get().forge or {}
if not forge_cfg.close then
return
end
local tasks = s:tasks() local tasks = s:tasks()
local pending_fetches = 0 local by_forge = {} ---@type table<string, pending.Task[]>
local any_changed = false
local any_fetched = false
for _, task in ipairs(tasks) do for _, task in ipairs(tasks) do
if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then if
local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] task.status ~= 'deleted'
pending_fetches = pending_fetches + 1 and task._extra
M.fetch_metadata(ref, function(cache) and task._extra._forge_ref
pending_fetches = pending_fetches - 1 and task._extra._forge_ref.type ~= 'repo'
if cache then then
task._extra._forge_cache = cache local fname = task._extra._forge_ref.forge
any_fetched = true if not by_forge[fname] then
local forge_cfg = config.get().forge or {} by_forge[fname] = {}
if end
forge_cfg.auto_close table.insert(by_forge[fname], task)
and (cache.state == 'closed' or cache.state == 'merged') end
and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') end
then local any_work = false
task.status = 'done' for fname, forge_tasks in pairs(by_forge) do
task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] if M.is_configured(fname) and _by_name[fname] then
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] any_work = true
any_changed = true M.check_auth(_by_name[fname], function(authed)
end if not authed then
else return
task._extra._forge_cache = {
state = 'open',
fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]],
}
end end
if pending_fetches == 0 then local remaining = #forge_tasks
if any_changed then local any_changed = false
s:save() local any_fetched = false
end for _, task in ipairs(forge_tasks) do
local buffer = require('pending.buffer') local ref = task._extra._forge_ref --[[@as pending.ForgeRef]]
if M.fetch_metadata(ref, function(cache)
(any_changed or any_fetched) remaining = remaining - 1
and buffer.bufnr() if cache then
and vim.api.nvim_buf_is_valid(buffer.bufnr()) task._extra._forge_cache = cache
then any_fetched = true
buffer.render() if
end (cache.state == 'closed' or cache.state == 'merged')
and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked')
then
task.status = 'done'
task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
any_changed = true
end
else
task._extra._forge_cache = {
state = 'open',
fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]],
}
end
if remaining == 0 then
if any_changed then
s:save()
end
local buffer = require('pending.buffer')
if
(any_changed or any_fetched)
and buffer.bufnr()
and vim.api.nvim_buf_is_valid(buffer.bufnr())
then
buffer.render()
end
end
end)
end end
end) end)
end end
end end
if pending_fetches == 0 then if not any_work then
log.info('No linked tasks to refresh.') log.info('No linked tasks to refresh.')
end end
end end
---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, default_icon?: string, default_issue_format?: string} ---@param refs pending.ForgeRef[]
function M.validate_refs(refs)
local by_forge = {} ---@type table<string, pending.ForgeRef[]>
for _, ref in ipairs(refs) do
if ref.type == 'repo' then
goto skip_ref
end
local fname = ref.forge
if not by_forge[fname] then
by_forge[fname] = {}
end
table.insert(by_forge[fname], ref)
::skip_ref::
end
for fname, forge_refs in pairs(by_forge) do
if not M.is_configured(fname) or not _by_name[fname] then
goto continue
end
M.check_auth(_by_name[fname], function(authed)
if not authed then
return
end
for _, ref in ipairs(forge_refs) do
M.fetch_metadata(ref, function(_, err)
if err and err.kind == 'not_found' then
log.warn(('%s:%s/%s#%d not found'):format(ref.forge, ref.owner, ref.repo, ref.number))
end
end)
end
end)
::continue::
end
end
---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string}
---@return pending.ForgeBackend ---@return pending.ForgeBackend
function M.gitea_backend(opts) function M.gitea_forge(opts)
return { return {
name = opts.name, name = opts.name,
shorthand = opts.shorthand, shorthand = opts.shorthand,
default_host = opts.default_host, default_host = opts.default_host,
cli = opts.cli or 'tea', cli = opts.cli or 'tea',
auth_cmd = opts.auth_cmd or 'tea login add', auth_cmd = opts.auth_cmd or 'tea login add',
auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' },
default_icon = opts.default_icon or '', default_icon = opts.default_icon or '',
default_issue_format = opts.default_issue_format or '%i %o/%r#%n', default_issue_format = opts.default_issue_format or '%i %o/%r#%n',
_warned = false,
parse_url = function(self, url) parse_url = function(self, url)
_ensure_instances() _ensure_instances()
local host, owner, repo, kind, number = local host, owner, repo, kind, number =
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
if not host then if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then
return nil local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
return {
forge = self.name,
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end end
if kind ~= 'issues' and kind ~= 'pulls' then host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
return nil if host and _by_host[host] == self then
return {
forge = self.name,
owner = owner,
repo = repo,
type = 'repo',
url = url,
}
end end
if _by_host[host] ~= self then return nil
return nil
end
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
return {
forge = self.name,
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end, end,
api_args = function(self, ref) api_args = function(self, ref)
local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues'
@ -387,32 +545,36 @@ M.register({
default_host = 'github.com', default_host = 'github.com',
cli = 'gh', cli = 'gh',
auth_cmd = 'gh auth login', auth_cmd = 'gh auth login',
auth_status_args = { 'gh', 'auth', 'status' },
default_icon = '', default_icon = '',
default_issue_format = '%i %o/%r#%n', default_issue_format = '%i %o/%r#%n',
_warned = false,
parse_url = function(self, url) parse_url = function(self, url)
_ensure_instances() _ensure_instances()
local host, owner, repo, kind, number = local host, owner, repo, kind, number =
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
if not host then if host and (kind == 'issues' or kind == 'pull') and _by_host[host] == self then
return nil local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pull' and 'pull_request' or 'issue'
return {
forge = 'github',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end end
if kind ~= 'issues' and kind ~= 'pull' then host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
return nil if host and _by_host[host] == self then
return {
forge = 'github',
owner = owner,
repo = repo,
type = 'repo',
url = url,
}
end end
if _by_host[host] ~= self then return nil
return nil
end
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pull' and 'pull_request' or 'issue'
return {
forge = 'github',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end, end,
api_args = function(_, ref) api_args = function(_, ref)
return { return {
@ -437,35 +599,44 @@ M.register({
default_host = 'gitlab.com', default_host = 'gitlab.com',
cli = 'glab', cli = 'glab',
auth_cmd = 'glab auth login', auth_cmd = 'glab auth login',
auth_status_args = { 'glab', 'auth', 'status' },
default_icon = '', default_icon = '',
default_issue_format = '%i %o/%r#%n', default_issue_format = '%i %o/%r#%n',
_warned = false,
parse_url = function(self, url) parse_url = function(self, url)
_ensure_instances() _ensure_instances()
local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$')
if not host then if host and (kind == 'issues' or kind == 'merge_requests') and _by_host[host] == self then
return nil local owner, repo = path:match('^(.+)/([^/]+)$')
if owner then
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue'
return {
forge = 'gitlab',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end
end end
if kind ~= 'issues' and kind ~= 'merge_requests' then host, path = url:match('^https?://([^/]+)/(.+)$')
return nil if host and _by_host[host] == self then
local trimmed = path:gsub('/$', '')
if not trimmed:find('/%-/') then
local owner, repo = trimmed:match('^(.+)/([^/]+)$')
if owner then
return {
forge = 'gitlab',
owner = owner,
repo = repo,
type = 'repo',
url = url,
}
end
end
end end
if _by_host[host] ~= self then return nil
return nil
end
local owner, repo = path:match('^(.+)/([^/]+)$')
if not owner then
return nil
end
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue'
return {
forge = 'gitlab',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end, end,
api_args = function(_, ref) api_args = function(_, ref)
local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F')
@ -492,32 +663,36 @@ M.register({
default_host = 'codeberg.org', default_host = 'codeberg.org',
cli = 'tea', cli = 'tea',
auth_cmd = 'tea login add', auth_cmd = 'tea login add',
auth_status_args = { 'tea', 'login', 'list' },
default_icon = '', default_icon = '',
default_issue_format = '%i %o/%r#%n', default_issue_format = '%i %o/%r#%n',
_warned = false,
parse_url = function(self, url) parse_url = function(self, url)
_ensure_instances() _ensure_instances()
local host, owner, repo, kind, number = local host, owner, repo, kind, number =
url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$')
if not host then if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then
return nil local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
return {
forge = 'codeberg',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end end
if kind ~= 'issues' and kind ~= 'pulls' then host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$')
return nil if host and _by_host[host] == self then
return {
forge = 'codeberg',
owner = owner,
repo = repo,
type = 'repo',
url = url,
}
end end
if _by_host[host] ~= self then return nil
return nil
end
local num = tonumber(number) --[[@as integer]]
local ref_type = kind == 'pulls' and 'pull_request' or 'issue'
return {
forge = 'codeberg',
owner = owner,
repo = repo,
type = ref_type,
number = num,
url = url,
}
end, end,
api_args = function(_, ref) api_args = function(_, ref)
local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues'

View file

@ -49,7 +49,9 @@ function M.check()
vim.health.start('pending.nvim: forge') vim.health.start('pending.nvim: forge')
local forge = require('pending.forge') local forge = require('pending.forge')
for _, backend in ipairs(forge.backends()) do for _, backend in ipairs(forge.backends()) do
if vim.fn.executable(backend.cli) == 1 then if not forge.is_configured(backend.name) then
vim.health.info(('%s: not configured (skipped)'):format(backend.name))
elseif vim.fn.executable(backend.cli) == 1 then
vim.health.ok(('%s found'):format(backend.cli)) vim.health.ok(('%s found'):format(backend.cli))
else else
vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) vim.health.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd))
@ -57,7 +59,7 @@ function M.check()
end end
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true) local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
if #sync_paths == 0 then if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then
vim.health.info('No sync backends found') vim.health.info('No sync backends found')
else else
for _, path in ipairs(sync_paths) do for _, path in ipairs(sync_paths) do
@ -68,6 +70,12 @@ function M.check()
backend.health() backend.health()
end end
end end
for rname, rbackend in pairs(require('pending').registered_backends()) do
if type(rbackend.health) == 'function' then
vim.health.start('pending.nvim: sync/' .. rname)
rbackend.health()
end
end
end end
end end

View file

@ -47,7 +47,7 @@ function M._recompute_counts()
local today_str = os.date('%Y-%m-%d') --[[@as string]] local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(get_store():active_tasks()) do for _, task in ipairs(get_store():active_tasks()) do
if task.status ~= 'done' and task.status ~= 'deleted' then if task.status ~= 'done' and task.status ~= 'deleted' and task.status ~= 'cancelled' then
pending = pending + 1 pending = pending + 1
if task.priority > 0 then if task.priority > 0 then
priority = priority + 1 priority = priority + 1
@ -173,6 +173,11 @@ local function compute_hidden_ids(tasks, predicates)
visible = false visible = false
break break
end end
elseif pred == 'cancelled' then
if task.status ~= 'cancelled' then
visible = false
break
end
end end
end end
if not visible then if not visible then
@ -368,6 +373,9 @@ function M._setup_buf_mappings(bufnr)
blocked = function() blocked = function()
M.toggle_status('blocked') M.toggle_status('blocked')
end, end,
cancelled = function()
M.toggle_status('cancelled')
end,
priority_up = function() priority_up = function()
M.increment_priority() M.increment_priority()
end, end,
@ -393,6 +401,9 @@ function M._setup_buf_mappings(bufnr)
open_line_above = function() open_line_above = function()
buffer.open_line(true) buffer.open_line(true)
end, end,
edit_notes = function()
M.open_detail()
end,
} }
for name, fn in pairs(actions) do for name, fn in pairs(actions) do
@ -402,6 +413,30 @@ function M._setup_buf_mappings(bufnr)
end end
end end
---@type table<string, fun()>
local visual_actions = {
priority_up_visual = function()
M.increment_priority_visual()
end,
priority_down_visual = function()
M.decrement_priority_visual()
end,
}
for name, fn in pairs(visual_actions) do
local key = km[name]
if key and key ~= false then
vim.keymap.set('x', key --[[@as string]], function()
vim.api.nvim_feedkeys(
vim.api.nvim_replace_termcodes('<Esc>', true, false, true),
'nx',
false
)
fn()
end, opts)
end
end
local textobj = require('pending.textobj') local textobj = require('pending.textobj')
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }> ---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
@ -489,9 +524,13 @@ function M._on_write(bufnr)
if #stack > UNDO_MAX then if #stack > UNDO_MAX then
table.remove(stack, 1) table.remove(stack, 1)
end end
diff.apply(lines, s, hidden) local new_refs = diff.apply(lines, s, hidden)
M._recompute_counts() M._recompute_counts()
buffer.render(bufnr) buffer.render(bufnr)
if new_refs and #new_refs > 0 then
local forge = require('pending.forge')
forge.validate_refs(new_refs)
end
end end
---@return nil ---@return nil
@ -520,7 +559,8 @@ function M.toggle_complete()
if not require_saved() then if not require_saved() then
return return
end end
local row = vim.api.nvim_win_get_cursor(0)[1] local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
local meta = buffer.meta() local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then if not meta[row] or meta[row].type ~= 'task' then
return return
@ -554,11 +594,25 @@ function M.toggle_complete()
end end
_save_and_notify() _save_and_notify()
buffer.render(bufnr) buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do local new_meta = buffer.meta()
if m.id == id then local total = #new_meta
vim.api.nvim_win_set_cursor(0, { lnum, 0 }) local target = math.min(row, total)
break if new_meta[target] and new_meta[target].type == 'task' then
vim.api.nvim_win_set_cursor(0, { target, col })
else
for r = target, total do
if new_meta[r] and new_meta[r].type == 'task' then
vim.api.nvim_win_set_cursor(0, { r, col })
return
end
end end
for r = target, 1, -1 do
if new_meta[r] and new_meta[r].type == 'task' then
vim.api.nvim_win_set_cursor(0, { r, col })
return
end
end
vim.api.nvim_win_set_cursor(0, { target, col })
end end
end end
@ -630,7 +684,8 @@ function M.toggle_priority()
if not require_saved() then if not require_saved() then
return return
end end
local row = vim.api.nvim_win_get_cursor(0)[1] local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
local meta = buffer.meta() local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then if not meta[row] or meta[row].type ~= 'task' then
return return
@ -651,7 +706,7 @@ function M.toggle_priority()
buffer.render(bufnr) buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do for lnum, m in ipairs(buffer.meta()) do
if m.id == id then if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 }) vim.api.nvim_win_set_cursor(0, { lnum, col })
break break
end end
end end
@ -667,7 +722,8 @@ local function adjust_priority(delta)
if not require_saved() then if not require_saved() then
return return
end end
local row = vim.api.nvim_win_get_cursor(0)[1] local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
local meta = buffer.meta() local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then if not meta[row] or meta[row].type ~= 'task' then
return return
@ -691,7 +747,7 @@ local function adjust_priority(delta)
buffer.render(bufnr) buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do for lnum, m in ipairs(buffer.meta()) do
if m.id == id then if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 }) vim.api.nvim_win_set_cursor(0, { lnum, col })
break break
end end
end end
@ -707,6 +763,53 @@ function M.decrement_priority()
adjust_priority(-1) adjust_priority(-1)
end end
---@param delta integer
---@return nil
local function adjust_priority_visual(delta)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local start_row = vim.fn.line("'<")
local end_row = vim.fn.line("'>")
local cursor = vim.api.nvim_win_get_cursor(0)
local meta = buffer.meta()
local s = get_store()
local max = require('pending.config').get().max_priority or 3
local changed = false
for row = start_row, end_row do
if meta[row] and meta[row].type == 'task' and meta[row].id then
local task = s:get(meta[row].id)
if task then
local new_priority = math.max(0, math.min(max, task.priority + delta))
if new_priority ~= task.priority then
s:update(meta[row].id, { priority = new_priority })
changed = true
end
end
end
end
if not changed then
return
end
_save_and_notify()
buffer.render(bufnr)
pcall(vim.api.nvim_win_set_cursor, 0, cursor)
end
---@return nil
function M.increment_priority_visual()
adjust_priority_visual(1)
end
---@return nil
function M.decrement_priority_visual()
adjust_priority_visual(-1)
end
---@return nil ---@return nil
function M.prompt_date() function M.prompt_date()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
@ -748,9 +851,48 @@ function M.prompt_date()
end) end)
end end
---@param target_status 'wip'|'blocked' ---@param target_status 'wip'|'blocked'|'cancelled'
---@return nil ---@return nil
function M.toggle_status(target_status) function M.toggle_status(target_status)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local s = get_store()
local task = s:get(id)
if not task then
return
end
if task.status == target_status then
s:update(id, { status = 'pending', ['end'] = vim.NIL })
else
s:update(id, { status = target_status })
end
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, col })
break
end
end
end
---@return nil
function M.open_detail()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
return return
@ -767,24 +909,26 @@ function M.toggle_status(target_status)
if not id then if not id then
return return
end end
local s = get_store()
local task = s:get(id) local detail_bufnr = buffer.open_detail(id)
if not task then if not detail_bufnr then
return return
end end
if task.status == target_status then
s:update(id, { status = 'pending' }) local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true })
else vim.api.nvim_create_autocmd('BufWriteCmd', {
s:update(id, { status = target_status }) group = group,
end buffer = detail_bufnr,
_save_and_notify() callback = function()
buffer.render(bufnr) buffer.save_detail()
for lnum, m in ipairs(buffer.meta()) do end,
if m.id == id then })
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break local km = require('pending.config').get().keymaps
end vim.keymap.set('n', km.close or 'q', function()
end vim.api.nvim_del_augroup_by_name('PendingDetail')
buffer.close_detail()
end, { buffer = detail_bufnr })
end end
---@param direction 'up'|'down' ---@param direction 'up'|'down'
@ -797,7 +941,8 @@ function M.move_task(direction)
if not require_saved() then if not require_saved() then
return return
end end
local row = vim.api.nvim_win_get_cursor(0)[1] local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
local meta = buffer.meta() local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then if not meta[row] or meta[row].type ~= 'task' then
return return
@ -872,7 +1017,7 @@ function M.move_task(direction)
for lnum, m in ipairs(buffer.meta()) do for lnum, m in ipairs(buffer.meta()) do
if m.id == id then if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 }) vim.api.nvim_win_set_cursor(0, { lnum, col })
break break
end end
end end
@ -980,10 +1125,10 @@ function M.add(text)
end end
s:add({ s:add({
description = description, description = description,
category = metadata.cat, category = metadata.category,
due = metadata.due, due = metadata.due,
recur = metadata.rec, recur = metadata.recur,
recur_mode = metadata.rec_mode, recur_mode = metadata.recur_mode,
priority = metadata.priority, priority = metadata.priority,
}) })
_save_and_notify() _save_and_notify()
@ -994,12 +1139,62 @@ function M.add(text)
log.info('Task added: ' .. description) log.info('Task added: ' .. description)
end end
---@class pending.SyncBackend
---@field name string
---@field auth? fun(sub_action?: string): nil
---@field push? fun(): nil
---@field pull? fun(): nil
---@field sync? fun(): nil
---@field health? fun(): nil
---@type table<string, pending.SyncBackend>
local _registered_backends = {}
---@type string[]? ---@type string[]?
local _sync_backends = nil local _sync_backends = nil
---@type table<string, true>? ---@type table<string, true>?
local _sync_backend_set = nil local _sync_backend_set = nil
---@param name string
---@return pending.SyncBackend?
function M.resolve_backend(name)
if _registered_backends[name] then
return _registered_backends[name]
end
local ok, mod = pcall(require, 'pending.sync.' .. name)
if ok and type(mod) == 'table' and mod.name then
return mod
end
return nil
end
---@param backend pending.SyncBackend
---@return nil
function M.register_backend(backend)
if type(backend) ~= 'table' or type(backend.name) ~= 'string' or backend.name == '' then
log.error('register_backend: backend must have a non-empty `name` field')
return
end
local builtin_ok, builtin = pcall(require, 'pending.sync.' .. backend.name)
if builtin_ok and type(builtin) == 'table' and builtin.name then
log.error('register_backend: backend `' .. backend.name .. '` already exists as a built-in')
return
end
if _registered_backends[backend.name] then
log.error('register_backend: backend `' .. backend.name .. '` is already registered')
return
end
_registered_backends[backend.name] = backend
_sync_backends = nil
_sync_backend_set = nil
end
---@return table<string, pending.SyncBackend>
function M.registered_backends()
return _registered_backends
end
---@return string[], table<string, true> ---@return string[], table<string, true>
local function discover_backends() local function discover_backends()
if _sync_backends then if _sync_backends then
@ -1016,6 +1211,12 @@ local function discover_backends()
_sync_backend_set[mod.name] = true _sync_backend_set[mod.name] = true
end end
end end
for name, _ in pairs(_registered_backends) do
if not _sync_backend_set[name] then
table.insert(_sync_backends, name)
_sync_backend_set[name] = true
end
end
table.sort(_sync_backends) table.sort(_sync_backends)
return _sync_backends, _sync_backend_set return _sync_backends, _sync_backend_set
end end
@ -1024,8 +1225,8 @@ end
---@param action? string ---@param action? string
---@return nil ---@return nil
local function run_sync(backend_name, action) local function run_sync(backend_name, action)
local ok, backend = pcall(require, 'pending.sync.' .. backend_name) local backend = M.resolve_backend(backend_name)
if not ok then if not backend then
log.error('Unknown sync backend: ' .. backend_name) log.error('Unknown sync backend: ' .. backend_name)
return return
end end
@ -1082,7 +1283,10 @@ function M.archive(arg)
log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks))
local count = 0 local count = 0
for _, task in ipairs(tasks) do for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then if
(task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled')
and task['end']
then
if task['end'] < cutoff then if task['end'] < cutoff then
count = count + 1 count = count + 1
end end
@ -1103,7 +1307,10 @@ function M.archive(arg)
function() function()
local kept = {} local kept = {}
for _, task in ipairs(tasks) do for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then if
(task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled')
and task['end']
then
if task['end'] < cutoff then if task['end'] < cutoff then
goto skip goto skip
end end
@ -1182,6 +1389,7 @@ end
local function parse_edit_token(token) local function parse_edit_token(token)
local recur = require('pending.recur') local recur = require('pending.recur')
local cfg = require('pending.config').get() local cfg = require('pending.config').get()
local ck = cfg.category_syntax or 'cat'
local dk = cfg.date_syntax or 'due' local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec' local rk = cfg.recur_syntax or 'rec'
@ -1197,7 +1405,7 @@ local function parse_edit_token(token)
if token == '-due' or token == '-' .. dk then if token == '-due' or token == '-' .. dk then
return 'due', vim.NIL, nil return 'due', vim.NIL, nil
end end
if token == '-cat' then if token == '-' .. ck then
return 'category', vim.NIL, nil return 'category', vim.NIL, nil
end end
if token == '-rec' or token == '-' .. rk then if token == '-rec' or token == '-' .. rk then
@ -1219,7 +1427,7 @@ local function parse_edit_token(token)
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.' 'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
end end
local cat_val = token:match('^cat:(.+)$') local cat_val = token:match('^' .. vim.pesc(ck) .. ':(.+)$')
if cat_val then if cat_val then
return 'category', cat_val, nil return 'category', cat_val, nil
end end
@ -1244,11 +1452,15 @@ local function parse_edit_token(token)
.. token .. token
.. '. Valid: ' .. '. Valid: '
.. dk .. dk
.. ':<date>, cat:<name>, ' .. ':<date>, '
.. ck
.. ':<name>, '
.. rk .. rk
.. ':<pattern>, +!, -!, -' .. ':<pattern>, +!, -!, -'
.. dk .. dk
.. ', -cat, -' .. ', -'
.. ck
.. ', -'
.. rk .. rk
end end
@ -1379,8 +1591,8 @@ function M.auth(args)
local backends_list = discover_backends() local backends_list = discover_backends()
local auth_backends = {} local auth_backends = {}
for _, name in ipairs(backends_list) do for _, name in ipairs(backends_list) do
local ok, mod = pcall(require, 'pending.sync.' .. name) local mod = M.resolve_backend(name)
if ok and type(mod.auth) == 'function' then if mod and type(mod.auth) == 'function' then
table.insert(auth_backends, { name = name, mod = mod }) table.insert(auth_backends, { name = name, mod = mod })
end end
end end

View file

@ -1,10 +1,12 @@
local config = require('pending.config') local config = require('pending.config')
local forge = require('pending.forge')
local log = require('pending.log')
---@class pending.Metadata ---@class pending.Metadata
---@field due? string ---@field due? string
---@field cat? string ---@field category? string
---@field rec? string ---@field recur? string
---@field rec_mode? 'scheduled'|'completion' ---@field recur_mode? pending.RecurMode
---@field priority? integer ---@field priority? integer
---@class pending.parse ---@class pending.parse
@ -107,6 +109,11 @@ local function is_valid_datetime(s)
return is_valid_date(date_part) and is_valid_time(time_part) return is_valid_date(date_part) and is_valid_time(time_part)
end end
---@return string
local function category_key()
return config.get().category_syntax or 'cat'
end
---@return string ---@return string
local function date_key() local function date_key()
return config.get().date_syntax or 'due' return config.get().date_syntax or 'due'
@ -530,83 +537,99 @@ function M.body(text)
end end
local metadata = {} local metadata = {}
local i = #tokens local ck = category_key()
local dk = date_key() local dk = date_key()
local rk = recur_key() local rk = recur_key()
local cat_pattern = '^' .. vim.pesc(ck) .. ':(%S+)$'
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$' local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$' local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$' local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
local desc_tokens = {}
local forge_tokens = {}
for _, token in ipairs(tokens) do
local consumed = false
while i >= 1 do
local token = tokens[i]
local due_val = token:match(date_pattern_strict) local due_val = token:match(date_pattern_strict)
if due_val then if due_val and is_valid_datetime(due_val) then
if metadata.due then if not metadata.due then
break metadata.due = due_val
else
log.warn('duplicate ' .. dk .. ': token ignored: ' .. token)
end end
if not is_valid_datetime(due_val) then consumed = true
break end
end if not consumed then
metadata.due = due_val
i = i - 1
else
local raw_val = token:match(date_pattern_any) local raw_val = token:match(date_pattern_any)
if raw_val then if raw_val then
if metadata.due then
break
end
local resolved = M.resolve_date(raw_val) local resolved = M.resolve_date(raw_val)
if not resolved then if resolved then
break if not metadata.due then
end metadata.due = resolved
metadata.due = resolved
i = i - 1
else
local cat_val = token:match('^cat:(%S+)$')
if cat_val then
if metadata.cat then
break
end
metadata.cat = cat_val
i = i - 1
else
local pri_bangs = token:match('^%+(!+)$')
if pri_bangs then
if metadata.priority then
break
end
local max = config.get().max_priority or 3
metadata.priority = math.min(#pri_bangs, max)
i = i - 1
else else
local rec_val = token:match(rec_pattern) log.warn('duplicate ' .. dk .. ': token ignored: ' .. token)
if rec_val then
if metadata.rec then
break
end
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
metadata.rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
break
end
metadata.rec = raw_spec
i = i - 1
else
break
end
end end
consumed = true
end end
end end
end end
if not consumed then
local cat_val = token:match(cat_pattern)
if cat_val then
if not metadata.category then
metadata.category = cat_val
else
log.warn('duplicate ' .. ck .. ': token ignored: ' .. token)
end
consumed = true
end
end
if not consumed then
local pri_bangs = token:match('^%+(!+)$')
if pri_bangs then
if not metadata.priority then
local max = config.get().max_priority or 3
metadata.priority = math.min(#pri_bangs, max)
else
log.warn('duplicate priority token ignored: ' .. token)
end
consumed = true
end
end
if not consumed then
local rec_val = token:match(rec_pattern)
if rec_val then
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
raw_spec = raw_spec:sub(2)
end
if recur.validate(raw_spec) then
if not metadata.recur then
metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil
metadata.recur = raw_spec
else
log.warn('duplicate ' .. rk .. ': token ignored: ' .. token)
end
consumed = true
end
end
end
if not consumed then
if forge.parse_ref(token) then
table.insert(forge_tokens, token)
else
table.insert(desc_tokens, token)
end
end
end end
local desc_tokens = {} for _, ft in ipairs(forge_tokens) do
for j = 1, i do table.insert(desc_tokens, ft)
table.insert(desc_tokens, tokens[j])
end end
local description = table.concat(desc_tokens, ' ') local description = table.concat(desc_tokens, ' ')
@ -624,7 +647,7 @@ function M.command_add(text)
local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$') local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$')
if rest then if rest then
local desc, meta = M.body(rest) local desc, meta = M.body(rest)
meta.cat = meta.cat or cat_prefix meta.category = meta.category or cat_prefix
return desc, meta return desc, meta
end end
end end

View file

@ -2,7 +2,7 @@
---@field freq 'daily'|'weekly'|'monthly'|'yearly' ---@field freq 'daily'|'weekly'|'monthly'|'yearly'
---@field interval integer ---@field interval integer
---@field byday? string[] ---@field byday? string[]
---@field from_completion boolean ---@field mode pending.RecurMode
---@field _raw? string ---@field _raw? string
---@class pending.recur ---@class pending.recur
@ -10,29 +10,29 @@ local M = {}
---@type table<string, pending.RecurSpec> ---@type table<string, pending.RecurSpec>
local named = { local named = {
daily = { freq = 'daily', interval = 1, from_completion = false }, daily = { freq = 'daily', interval = 1, mode = 'scheduled' },
weekdays = { weekdays = {
freq = 'weekly', freq = 'weekly',
interval = 1, interval = 1,
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' }, byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
from_completion = false, mode = 'scheduled',
}, },
weekly = { freq = 'weekly', interval = 1, from_completion = false }, weekly = { freq = 'weekly', interval = 1, mode = 'scheduled' },
biweekly = { freq = 'weekly', interval = 2, from_completion = false }, biweekly = { freq = 'weekly', interval = 2, mode = 'scheduled' },
monthly = { freq = 'monthly', interval = 1, from_completion = false }, monthly = { freq = 'monthly', interval = 1, mode = 'scheduled' },
quarterly = { freq = 'monthly', interval = 3, from_completion = false }, quarterly = { freq = 'monthly', interval = 3, mode = 'scheduled' },
yearly = { freq = 'yearly', interval = 1, from_completion = false }, yearly = { freq = 'yearly', interval = 1, mode = 'scheduled' },
annual = { freq = 'yearly', interval = 1, from_completion = false }, annual = { freq = 'yearly', interval = 1, mode = 'scheduled' },
} }
---@param spec string ---@param spec string
---@return pending.RecurSpec? ---@return pending.RecurSpec?
function M.parse(spec) function M.parse(spec)
local from_completion = false local mode = 'scheduled' ---@type pending.RecurMode
local s = spec local s = spec
if s:sub(1, 1) == '!' then if s:sub(1, 1) == '!' then
from_completion = true mode = 'completion'
s = s:sub(2) s = s:sub(2)
end end
@ -44,7 +44,7 @@ function M.parse(spec)
freq = base.freq, freq = base.freq,
interval = base.interval, interval = base.interval,
byday = base.byday, byday = base.byday,
from_completion = from_completion, mode = mode,
} }
end end
@ -58,7 +58,7 @@ function M.parse(spec)
return { return {
freq = freq_map[unit], freq = freq_map[unit],
interval = num, interval = num,
from_completion = from_completion, mode = mode,
} }
end end
@ -66,7 +66,7 @@ function M.parse(spec)
return { return {
freq = 'daily', freq = 'daily',
interval = 1, interval = 1,
from_completion = from_completion, mode = mode,
_raw = s, _raw = s,
} }
end end
@ -134,7 +134,7 @@ end
---@param base_date string ---@param base_date string
---@param spec string ---@param spec string
---@param mode 'scheduled'|'completion' ---@param mode pending.RecurMode
---@return string ---@return string
function M.next_due(base_date, spec, mode) function M.next_due(base_date, spec, mode)
local parsed = M.parse(spec) local parsed = M.parse(spec)

View file

@ -1,19 +1,32 @@
local config = require('pending.config') local config = require('pending.config')
---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'|'cancelled'
---@alias pending.RecurMode 'scheduled'|'completion'
---@class pending.TaskExtra
---@field _forge_ref? pending.ForgeRef
---@field _forge_cache? pending.ForgeCache
---@field _gtasks_task_id? string
---@field _gtasks_list_id? string
---@field _gcal_event_id? string
---@field _gcal_calendar_id? string
---@field [string] any
---@class pending.Task ---@class pending.Task
---@field id integer ---@field id integer
---@field description string ---@field description string
---@field status 'pending'|'done'|'deleted'|'wip'|'blocked' ---@field status pending.TaskStatus
---@field category? string ---@field category? string
---@field priority integer ---@field priority integer
---@field due? string ---@field due? string
---@field recur? string ---@field recur? string
---@field recur_mode? 'scheduled'|'completion' ---@field recur_mode? pending.RecurMode
---@field entry string ---@field entry string
---@field modified string ---@field modified string
---@field end? string ---@field end? string
---@field notes? string
---@field order integer ---@field order integer
---@field _extra? table<string, any> ---@field _extra? pending.TaskExtra
---@class pending.Data ---@class pending.Data
---@field version integer ---@field version integer
@ -24,14 +37,14 @@ local config = require('pending.config')
---@class pending.TaskFields ---@class pending.TaskFields
---@field description string ---@field description string
---@field status? string ---@field status? pending.TaskStatus
---@field category? string ---@field category? string
---@field priority? integer ---@field priority? integer
---@field due? string ---@field due? string
---@field recur? string ---@field recur? string
---@field recur_mode? string ---@field recur_mode? pending.RecurMode
---@field order? integer ---@field order? integer
---@field _extra? table ---@field _extra? pending.TaskExtra
---@class pending.Store ---@class pending.Store
---@field path string ---@field path string
@ -81,6 +94,7 @@ local known_fields = {
entry = true, entry = true,
modified = true, modified = true,
['end'] = true, ['end'] = true,
notes = true,
order = true, order = true,
} }
@ -112,6 +126,9 @@ local function task_to_table(task)
if task['end'] then if task['end'] then
t['end'] = task['end'] t['end'] = task['end']
end end
if task.notes then
t.notes = task.notes
end
if task.order and task.order ~= 0 then if task.order and task.order ~= 0 then
t.order = task.order t.order = task.order
end end
@ -138,6 +155,7 @@ local function table_to_task(t)
entry = t.entry, entry = t.entry,
modified = t.modified, modified = t.modified,
['end'] = t['end'], ['end'] = t['end'],
notes = t.notes,
order = t.order or 0, order = t.order or 0,
_extra = {}, _extra = {},
} }
@ -319,7 +337,7 @@ function Store:update(id, fields)
end end
end end
task.modified = now task.modified = now
if fields.status == 'done' or fields.status == 'deleted' then if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then
task['end'] = task['end'] or now task['end'] = task['end'] or now
end end
return task return task

View file

@ -177,6 +177,7 @@ function M.push()
and ( and (
task.status == 'done' task.status == 'done'
or task.status == 'deleted' or task.status == 'deleted'
or task.status == 'cancelled'
or (task.status == 'pending' and not task.due) or (task.status == 'pending' and not task.due)
) )

View file

@ -24,6 +24,15 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER'
---@field config_key string ---@field config_key string
---@class pending.OAuthClient : pending.OAuthClientOpts ---@class pending.OAuthClient : pending.OAuthClientOpts
---@field token_path fun(self: pending.OAuthClient): string
---@field resolve_credentials fun(self: pending.OAuthClient): pending.OAuthCredentials
---@field load_tokens fun(self: pending.OAuthClient): pending.OAuthTokens?
---@field save_tokens fun(self: pending.OAuthClient, tokens: pending.OAuthTokens): boolean
---@field refresh_access_token fun(self: pending.OAuthClient, creds: pending.OAuthCredentials, tokens: pending.OAuthTokens): pending.OAuthTokens?
---@field get_access_token fun(self: pending.OAuthClient): string?
---@field setup fun(self: pending.OAuthClient): nil
---@field auth fun(self: pending.OAuthClient, on_complete?: fun(ok: boolean): nil): nil
---@field clear_tokens fun(self: pending.OAuthClient): nil
local OAuthClient = {} local OAuthClient = {}
OAuthClient.__index = OAuthClient OAuthClient.__index = OAuthClient

View file

@ -13,7 +13,7 @@ local parse = require('pending.parse')
---@field id? integer ---@field id? integer
---@field due? string ---@field due? string
---@field raw_due? string ---@field raw_due? string
---@field status? string ---@field status? pending.TaskStatus
---@field category? string ---@field category? string
---@field overdue? boolean ---@field overdue? boolean
---@field show_category? boolean ---@field show_category? boolean
@ -71,21 +71,24 @@ local function compute_forge_spans(task, prefix_len)
end end
---@type table<string, integer> ---@type table<string, integer>
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3, cancelled = 4 }
---@param task pending.Task ---@param task pending.Task
---@return string ---@return string
local function state_char(task) local function state_char(task)
local icons = config.get().icons
if task.status == 'done' then if task.status == 'done' then
return 'x' return icons.done
elseif task.status == 'cancelled' then
return icons.cancelled
elseif task.status == 'wip' then elseif task.status == 'wip' then
return '>' return icons.wip
elseif task.status == 'blocked' then elseif task.status == 'blocked' then
return '=' return icons.blocked
elseif task.priority > 0 then elseif task.priority > 0 then
return '!' return icons.priority
end end
return ' ' return icons.pending
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
@ -106,17 +109,23 @@ local function sort_tasks(tasks)
end) end)
end end
---@param tasks pending.Task[] ---@type table<string, fun(a: pending.Task, b: pending.Task): boolean?>
local function sort_tasks_priority(tasks) local sort_key_comparators = {
table.sort(tasks, function(a, b) status = function(a, b)
local ra = status_rank[a.status] or 1 local ra = status_rank[a.status] or 1
local rb = status_rank[b.status] or 1 local rb = status_rank[b.status] or 1
if ra ~= rb then if ra ~= rb then
return ra < rb return ra < rb
end end
return nil
end,
priority = function(a, b)
if a.priority ~= b.priority then if a.priority ~= b.priority then
return a.priority > b.priority return a.priority > b.priority
end end
return nil
end,
due = function(a, b)
local a_due = a.due or '' local a_due = a.due or ''
local b_due = b.due or '' local b_due = b.due or ''
if a_due ~= b_due then if a_due ~= b_due then
@ -128,11 +137,61 @@ local function sort_tasks_priority(tasks)
end end
return a_due < b_due return a_due < b_due
end end
return nil
end,
order = function(a, b)
if a.order ~= b.order then if a.order ~= b.order then
return a.order < b.order return a.order < b.order
end end
return a.id < b.id return nil
end) end,
id = function(a, b)
if a.id ~= b.id then
return a.id < b.id
end
return nil
end,
age = function(a, b)
if a.id ~= b.id then
return a.id < b.id
end
return nil
end,
}
---@return fun(a: pending.Task, b: pending.Task): boolean
local function build_queue_comparator()
local log = require('pending.log')
local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' }
local comparators = {}
local unknown = {}
for _, key in ipairs(keys) do
local cmp = sort_key_comparators[key]
if cmp then
table.insert(comparators, cmp)
else
table.insert(unknown, key)
end
end
if #unknown > 0 then
local label = #unknown == 1 and 'unknown queue sort key: ' or 'unknown queue sort keys: '
log.warn(label .. table.concat(unknown, ', '))
end
return function(a, b)
for _, cmp in ipairs(comparators) do
local result = cmp(a, b)
if result ~= nil then
return result
end
end
return false
end
end
---@param tasks pending.Task[]
local function sort_tasks_priority(tasks)
local cmp = build_queue_comparator()
table.sort(tasks, cmp)
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
@ -152,7 +211,7 @@ function M.category_view(tasks)
by_cat[cat] = {} by_cat[cat] = {}
done_by_cat[cat] = {} done_by_cat[cat] = {}
end end
if task.status == 'done' or task.status == 'deleted' then if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then
table.insert(done_by_cat[cat], task) table.insert(done_by_cat[cat], task)
else else
table.insert(by_cat[cat], task) table.insert(by_cat[cat], task)
@ -215,7 +274,11 @@ function M.category_view(tasks)
status = task.status, status = task.status,
category = cat, category = cat,
priority = task.priority, priority = task.priority,
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, overdue = task.status ~= 'done'
and task.status ~= 'cancelled'
and task.due ~= nil
and parse.is_overdue(task.due)
or nil,
recur = task.recur, recur = task.recur,
forge_spans = compute_forge_spans(task, prefix_len), forge_spans = compute_forge_spans(task, prefix_len),
}) })
@ -233,7 +296,7 @@ function M.priority_view(tasks)
local done = {} local done = {}
for _, task in ipairs(tasks) do for _, task in ipairs(tasks) do
if task.status == 'done' then if task.status == 'done' or task.status == 'cancelled' then
table.insert(done, task) table.insert(done, task)
else else
table.insert(pending, task) table.insert(pending, task)
@ -256,7 +319,7 @@ function M.priority_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local state = state_char(task)
local line = prefix .. '- [' .. state .. '] ' .. task.description local line = prefix .. '- [' .. state .. '] ' .. task.description
local prefix_len = #prefix + #('- [' .. state .. '] ') local prefix_len = #prefix + #('- [' .. state .. '] ')
table.insert(lines, line) table.insert(lines, line)
@ -268,12 +331,17 @@ function M.priority_view(tasks)
status = task.status, status = task.status,
category = task.category, category = task.category,
priority = task.priority, priority = task.priority,
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, overdue = task.status ~= 'done'
and task.status ~= 'cancelled'
and task.due ~= nil
and parse.is_overdue(task.due)
or nil,
show_category = true, show_category = true,
recur = task.recur, recur = task.recur,
forge_ref = task._extra and task._extra._forge_ref or nil, forge_ref = task._extra and task._extra._forge_ref or nil,
forge_cache = task._extra and task._extra._forge_cache or nil, forge_cache = task._extra and task._extra._forge_cache or nil,
forge_spans = compute_forge_spans(task, prefix_len), forge_spans = compute_forge_spans(task, prefix_len),
has_notes = task.notes ~= nil and task.notes ~= '',
}) })
end end

View file

@ -6,18 +6,19 @@ vim.g.loaded_pending = true
---@return string[] ---@return string[]
local function edit_field_candidates() local function edit_field_candidates()
local cfg = require('pending.config').get() local cfg = require('pending.config').get()
local ck = cfg.category_syntax or 'cat'
local dk = cfg.date_syntax or 'due' local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec' local rk = cfg.recur_syntax or 'rec'
return { return {
dk .. ':', dk .. ':',
'cat:', ck .. ':',
rk .. ':', rk .. ':',
'+!', '+!',
'+!!', '+!!',
'+!!!', '+!!!',
'-!', '-!',
'-' .. dk, '-' .. dk,
'-cat', '-' .. ck,
'-' .. rk, '-' .. rk,
} }
end end
@ -79,6 +80,65 @@ local function filter_candidates(lead, candidates)
end, candidates) end, candidates)
end end
---@param arg_lead string
---@return string[]
local function complete_add(arg_lead)
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local ck = cfg.category_syntax or 'cat'
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
if prefix then
local after_colon = arg_lead:sub(#prefix + 1)
local result = {}
for _, d in ipairs(edit_date_values()) do
if d:find(after_colon, 1, true) == 1 then
table.insert(result, prefix .. d)
end
end
return result
end
local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$')
if rec_prefix then
local after_colon = arg_lead:sub(#rec_prefix + 1)
local result = {}
for _, p in ipairs(edit_recur_values()) do
if p:find(after_colon, 1, true) == 1 then
table.insert(result, rec_prefix .. p)
end
end
return result
end
local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$')
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local seen = {}
local cats = {}
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(cats, task.category)
end
end
table.sort(cats)
local result = {}
for _, c in ipairs(cats) do
if c:find(after_colon, 1, true) == 1 then
table.insert(result, cat_prefix .. c)
end
end
return result
end
return {}
end
---@param arg_lead string ---@param arg_lead string
---@param cmd_line string ---@param cmd_line string
---@return string[] ---@return string[]
@ -135,7 +195,9 @@ local function complete_edit(arg_lead, cmd_line)
return result return result
end end
local cat_prefix = arg_lead:match('^(cat:)(.*)$') local ck = cfg.category_syntax or 'cat'
local cat_prefix = arg_lead:match('^(' .. vim.pesc(ck) .. ':)(.*)$')
if cat_prefix then if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1) local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store') local store = require('pending.store')
@ -183,8 +245,17 @@ end, {
for word in after_filter:gmatch('%S+') do for word in after_filter:gmatch('%S+') do
used[word] = true used[word] = true
end end
local candidates = local candidates = {
{ 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } 'clear',
'overdue',
'today',
'priority',
'done',
'pending',
'wip',
'blocked',
'cancelled',
}
local store = require('pending.store') local store = require('pending.store')
local s = store.new(store.resolve_path()) local s = store.new(store.resolve_path())
s:load() s:load()
@ -192,7 +263,8 @@ end, {
for _, task in ipairs(s:active_tasks()) do for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then if task.category and not seen[task.category] then
seen[task.category] = true seen[task.category] = true
table.insert(candidates, 'cat:' .. task.category) local ck = (require('pending.config').get().category_syntax or 'cat')
table.insert(candidates, ck .. ':' .. task.category)
end end
end end
local filtered = {} local filtered = {}
@ -203,6 +275,9 @@ end, {
end end
return filtered return filtered
end end
if cmd_line:match('^Pending%s+add%s') then
return complete_add(arg_lead)
end
if cmd_line:match('^Pending%s+archive%s') then if cmd_line:match('^Pending%s+archive%s') then
return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' }) return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' })
end end
@ -229,8 +304,8 @@ end, {
if #parts == 0 or (#parts == 1 and not trailing) then if #parts == 0 or (#parts == 1 and not trailing) then
local auth_names = {} local auth_names = {}
for _, b in ipairs(pending.sync_backends()) do for _, b in ipairs(pending.sync_backends()) do
local ok, mod = pcall(require, 'pending.sync.' .. b) local mod = pending.resolve_backend(b)
if ok and type(mod.auth) == 'function' then if mod and type(mod.auth) == 'function' then
table.insert(auth_names, b) table.insert(auth_names, b)
end end
end end
@ -238,8 +313,8 @@ end, {
end end
local backend_name = parts[1] local backend_name = parts[1]
if #parts == 1 or (#parts == 2 and not trailing) then if #parts == 1 or (#parts == 2 and not trailing) then
local ok, mod = pcall(require, 'pending.sync.' .. backend_name) local mod = pending.resolve_backend(backend_name)
if ok and type(mod.auth_complete) == 'function' then if mod and type(mod.auth_complete) == 'function' then
return filter_candidates(arg_lead, mod.auth_complete()) return filter_candidates(arg_lead, mod.auth_complete())
end end
return {} return {}
@ -253,8 +328,8 @@ end, {
if not after_backend then if not after_backend then
return {} return {}
end end
local ok, mod = pcall(require, 'pending.sync.' .. matched_backend) local mod = pending.resolve_backend(matched_backend)
if not ok then if not mod then
return {} return {}
end end
local actions = {} local actions = {}
@ -328,6 +403,14 @@ vim.keymap.set('n', '<Plug>(pending-blocked)', function()
require('pending').toggle_status('blocked') require('pending').toggle_status('blocked')
end) end)
vim.keymap.set('n', '<Plug>(pending-cancelled)', function()
require('pending').toggle_status('cancelled')
end)
vim.keymap.set('n', '<Plug>(pending-edit-notes)', function()
require('pending').open_detail()
end)
vim.keymap.set('n', '<Plug>(pending-priority-up)', function() vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
require('pending').increment_priority() require('pending').increment_priority()
end) end)
@ -336,6 +419,16 @@ vim.keymap.set('n', '<Plug>(pending-priority-down)', function()
require('pending').decrement_priority() require('pending').decrement_priority()
end) end)
vim.keymap.set('x', '<Plug>(pending-priority-up-visual)', function()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', false)
require('pending').increment_priority_visual()
end)
vim.keymap.set('x', '<Plug>(pending-priority-down-visual)', function()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'nx', false)
require('pending').decrement_priority_visual()
end)
vim.keymap.set('n', '<Plug>(pending-filter)', function() vim.keymap.set('n', '<Plug>(pending-filter)', function()
vim.ui.input({ prompt = 'Filter: ' }, function(input) vim.ui.input({ prompt = 'Filter: ' }, function(input)
if input then if input then

402
spec/detail_spec.lua Normal file
View file

@ -0,0 +1,402 @@
require('spec.helpers')
local config = require('pending.config')
describe('detail frontmatter', function()
local buffer
local tmpdir
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
buffer = require('pending.buffer')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('build_detail_frontmatter', function()
it('renders status and priority for minimal task', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: pending', lines[1])
assert.are.equal('Priority: 0', lines[2])
end)
it('renders all fields', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'wip',
priority = 2,
category = 'Work',
due = '2026-03-15',
recur = 'weekly',
entry = '',
modified = '',
order = 0,
})
assert.are.equal(5, #lines)
assert.are.equal('Status: wip', lines[1])
assert.are.equal('Priority: 2', lines[2])
assert.are.equal('Category: Work', lines[3])
assert.are.equal('Due: 2026-03-15', lines[4])
assert.are.equal('Recur: weekly', lines[5])
end)
it('prefixes recur with ! for completion mode', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
recur = 'daily',
recur_mode = 'completion',
entry = '',
modified = '',
order = 0,
})
assert.are.equal('Recur: !daily', lines[3])
end)
it('omits optional fields when absent', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'done',
priority = 1,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: done', lines[1])
assert.are.equal('Priority: 1', lines[2])
end)
end)
describe('parse_detail_frontmatter', function()
it('parses minimal frontmatter', function()
local lines = {
'# My task',
'Status: pending',
'Priority: 0',
'---',
'some notes',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(4, sep)
assert.are.equal('My task', fields.description)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('parses all fields', function()
local lines = {
'# Fix the bug',
'Status: wip',
'Priority: 2',
'Category: Work',
'Due: 2026-03-15',
'Recur: weekly',
'---',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(7, sep)
assert.are.equal('Fix the bug', fields.description)
assert.are.equal('wip', fields.status)
assert.are.equal(2, fields.priority)
assert.are.equal('Work', fields.category)
assert.are.equal('2026-03-15', fields.due)
assert.are.equal('weekly', fields.recur)
end)
it('resolves due date keywords', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: tomorrow',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
)
assert.are.equal(expected, fields.due)
end)
it('parses completion-mode recurrence', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Recur: !daily',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('daily', fields.recur)
assert.are.equal('completion', fields.recur_mode)
end)
it('clears optional fields when lines removed', function()
local lines = {
'# Task',
'Status: done',
'Priority: 1',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(vim.NIL, fields.category)
assert.are.equal(vim.NIL, fields.due)
assert.are.equal(vim.NIL, fields.recur)
end)
it('skips blank lines in frontmatter', function()
local lines = {
'# Task',
'Status: pending',
'',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('errors on missing separator', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing separator'))
end)
it('errors on missing title', function()
local lines = {
'',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on empty title', function()
local lines = {
'# ',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on invalid status', function()
local lines = {
'# Task',
'Status: bogus',
'Priority: 0',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid status'))
end)
it('errors on negative priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: -1',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on non-integer priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 1.5',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on priority exceeding max', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 4',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('max is 3'))
end)
it('errors on invalid due date', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: notadate',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid due date'))
end)
it('errors on empty due value', function()
local lines = {
'# Task',
'Due: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty due value'))
end)
it('errors on invalid recurrence', function()
local lines = {
'# Task',
'Recur: nope',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid recurrence'))
end)
it('errors on empty recur value', function()
local lines = {
'# Task',
'Recur: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty recur value'))
end)
it('errors on empty category value', function()
local lines = {
'# Task',
'Category: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty category'))
end)
it('errors on unknown field', function()
local lines = {
'# Task',
'Status: pending',
'Foo: bar',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('unknown field: foo'))
end)
it('errors on duplicate field', function()
local lines = {
'# Task',
'Status: pending',
'Status: done',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('duplicate field'))
end)
it('errors on malformed frontmatter line', function()
local lines = {
'# Task',
'not a key value pair',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid frontmatter line'))
end)
it('is case-insensitive for field keys', function()
local lines = {
'# Task',
'STATUS: wip',
'PRIORITY: 1',
'CATEGORY: Work',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('wip', fields.status)
assert.are.equal(1, fields.priority)
assert.are.equal('Work', fields.category)
end)
it('accepts datetime due format', function()
local lines = {
'# Task',
'Due: 2026-03-15T14:00',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('2026-03-15T14:00', fields.due)
end)
it('respects custom max_priority', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 }
config.reset()
local lines = {
'# Task',
'Priority: 5',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(5, fields.priority)
end)
it('updates description from title line', function()
local lines = {
'# Updated title',
'Status: pending',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('Updated title', fields.description)
end)
end)
end)

View file

@ -71,7 +71,7 @@ describe('diff', function()
'/1/- [ ] Take trash out rec:weekly', '/1/- [ ] Take trash out rec:weekly',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal('weekly', result[2].rec) assert.are.equal('weekly', result[2].recur)
end) end)
it('extracts rec: with completion mode', function() it('extracts rec: with completion mode', function()
@ -80,8 +80,8 @@ describe('diff', function()
'/1/- [ ] Water plants rec:!daily', '/1/- [ ] Water plants rec:!daily',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal('daily', result[2].rec) assert.are.equal('daily', result[2].recur)
assert.are.equal('completion', result[2].rec_mode) assert.are.equal('completion', result[2].recur_mode)
end) end)
it('inline due: token is parsed', function() it('inline due: token is parsed', function()
@ -275,6 +275,98 @@ describe('diff', function()
assert.are.equal('completion', tasks[1].recur_mode) assert.are.equal('completion', tasks[1].recur_mode)
end) end)
it('returns forge refs for new tasks', function()
local lines = {
'# Inbox',
'- [ ] Fix bug gh:user/repo#42',
}
local refs = diff.apply(lines, s)
assert.are.equal(1, #refs)
assert.are.equal('github', refs[1].forge)
assert.are.equal(42, refs[1].number)
end)
it('returns forge refs for changed refs on existing tasks', function()
s:add({
description = 'Fix bug gh:user/repo#1',
_extra = {
_forge_ref = {
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 1,
url = '',
},
},
})
s:save()
local lines = {
'# Todo',
'/1/- [ ] Fix bug gh:user/repo#99',
}
local refs = diff.apply(lines, s)
assert.are.equal(1, #refs)
assert.are.equal(99, refs[1].number)
end)
it('returns empty when forge ref is unchanged', function()
s:add({
description = 'Fix bug gh:user/repo#42',
_extra = {
_forge_ref = {
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
},
},
})
s:save()
local lines = {
'# Todo',
'/1/- [ ] Fix bug gh:user/repo#42',
}
local refs = diff.apply(lines, s)
assert.are.equal(0, #refs)
end)
it('returns empty for tasks without forge refs', function()
local lines = {
'# Inbox',
'- [ ] Plain task',
}
local refs = diff.apply(lines, s)
assert.are.equal(0, #refs)
end)
it('returns forge refs for duplicated tasks', function()
s:add({
description = 'Fix bug gh:user/repo#42',
_extra = {
_forge_ref = {
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
},
},
})
s:save()
local lines = {
'# Todo',
'/1/- [ ] Fix bug gh:user/repo#42',
'/1/- [ ] Fix bug gh:user/repo#42',
}
local refs = diff.apply(lines, s)
assert.are.equal(1, #refs)
assert.are.equal(42, refs[1].number)
end)
it('clears priority when [N] is removed from buffer line', function() it('clears priority when [N] is removed from buffer line', function()
s:add({ description = 'Task name', priority = 1 }) s:add({ description = 'Task name', priority = 1 })
s:save() s:save()
@ -287,5 +379,41 @@ describe('diff', function()
local task = s:get(1) local task = s:get(1)
assert.are.equal(0, task.priority) assert.are.equal(0, task.priority)
end) end)
it('sets priority from +!! token', function()
local lines = {
'# Inbox',
'- [ ] Pay bills +!!',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal(2, task.priority)
end)
it('updates priority between non-zero values', function()
s:add({ description = 'Task name', priority = 2 })
s:save()
local lines = {
'# Inbox',
'/1/- [!] Task name',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal(1, task.priority)
end)
it('parses metadata with forge ref on same line', function()
local lines = {
'# Inbox',
'- [ ] Fix bug due:2026-03-15 gh:user/repo#42',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('2026-03-15', task.due)
assert.is_not_nil(task._extra._forge_ref)
end)
end) end)
end) end)

View file

@ -44,12 +44,30 @@ describe('forge', function()
assert.is_nil(forge._parse_shorthand('xx:user/repo#1')) assert.is_nil(forge._parse_shorthand('xx:user/repo#1'))
end) end)
it('rejects missing number', function() it('parses bare gh: shorthand without number', function()
assert.is_nil(forge._parse_shorthand('gh:user/repo')) local ref = forge._parse_shorthand('gh:user/repo')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
assert.equals('https://github.com/user/repo', ref.url)
end)
it('parses bare gl: shorthand without number', function()
local ref = forge._parse_shorthand('gl:group/project')
assert.is_not_nil(ref)
assert.equals('gitlab', ref.forge)
assert.equals('group', ref.owner)
assert.equals('project', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end) end)
it('rejects missing repo', function() it('rejects missing repo', function()
assert.is_nil(forge._parse_shorthand('gh:user#1')) assert.is_nil(forge._parse_shorthand('gh:user#1'))
assert.is_nil(forge._parse_shorthand('gh:user'))
end) end)
end) end)
@ -73,6 +91,23 @@ describe('forge', function()
it('rejects non-github URL', function() it('rejects non-github URL', function()
assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1')) assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1'))
end) end)
it('parses bare repo URL', function()
local ref = forge._parse_github_url('https://github.com/user/repo')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
it('parses bare repo URL with trailing slash', function()
local ref = forge._parse_github_url('https://github.com/user/repo/')
assert.is_not_nil(ref)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
end) end)
describe('_parse_gitlab_url', function() describe('_parse_gitlab_url', function()
@ -98,6 +133,16 @@ describe('forge', function()
assert.equals('org/sub', ref.owner) assert.equals('org/sub', ref.owner)
assert.equals('project', ref.repo) assert.equals('project', ref.repo)
end) end)
it('parses bare repo URL', function()
local ref = forge._parse_gitlab_url('https://gitlab.com/group/project')
assert.is_not_nil(ref)
assert.equals('gitlab', ref.forge)
assert.equals('group', ref.owner)
assert.equals('project', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
end) end)
describe('_parse_codeberg_url', function() describe('_parse_codeberg_url', function()
@ -116,6 +161,16 @@ describe('forge', function()
assert.is_not_nil(ref) assert.is_not_nil(ref)
assert.equals('pull_request', ref.type) assert.equals('pull_request', ref.type)
end) end)
it('parses bare repo URL', function()
local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo')
assert.is_not_nil(ref)
assert.equals('codeberg', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
end) end)
describe('parse_ref', function() describe('parse_ref', function()
@ -141,6 +196,14 @@ describe('forge', function()
assert.is_nil(forge.parse_ref('hello')) assert.is_nil(forge.parse_ref('hello'))
assert.is_nil(forge.parse_ref('due:tomorrow')) assert.is_nil(forge.parse_ref('due:tomorrow'))
end) end)
it('dispatches bare shorthand', function()
local ref = forge.parse_ref('gh:user/repo')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('repo', ref.type)
assert.is_nil(ref.number)
end)
end) end)
describe('find_refs', function() describe('find_refs', function()
@ -184,6 +247,17 @@ describe('forge', function()
assert.equals(0, refs[1].start_byte) assert.equals(0, refs[1].start_byte)
assert.equals(8, refs[1].end_byte) assert.equals(8, refs[1].end_byte)
end) end)
it('finds bare shorthand ref', function()
local refs = forge.find_refs('Fix gh:user/repo')
assert.equals(1, #refs)
assert.equals('github', refs[1].ref.forge)
assert.equals('repo', refs[1].ref.type)
assert.is_nil(refs[1].ref.number)
assert.equals('gh:user/repo', refs[1].raw)
assert.equals(4, refs[1].start_byte)
assert.equals(16, refs[1].end_byte)
end)
end) end)
describe('_api_args', function() describe('_api_args', function()
@ -262,6 +336,30 @@ describe('forge', function()
assert.equals('PendingForgeClosed', hl) assert.equals('PendingForgeClosed', hl)
end) end)
it('formats bare repo ref without #N', function()
local text = forge.format_label({
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'repo',
url = '',
}, nil)
assert.truthy(text:find('user/repo'))
assert.is_nil(text:find('#'))
end)
it('still formats numbered ref with #N', function()
local text = forge.format_label({
forge = 'github',
owner = 'user',
repo = 'repo',
type = 'issue',
number = 42,
url = '',
}, nil)
assert.truthy(text:find('user/repo#42'))
end)
it('uses closed highlight for merged state', function() it('uses closed highlight for merged state', function()
local _, hl = forge.format_label({ local _, hl = forge.format_label({
forge = 'gitlab', forge = 'gitlab',
@ -306,7 +404,7 @@ describe('forge parse.body integration', function()
it('extracts category but keeps forge ref in description', function() it('extracts category but keeps forge ref in description', function()
local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work') local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work')
assert.equals('Fix bug gh:user/repo#42', desc) assert.equals('Fix bug gh:user/repo#42', desc)
assert.equals('Work', meta.cat) assert.equals('Work', meta.category)
end) end)
it('leaves non-forge tokens as description', function() it('leaves non-forge tokens as description', function()
@ -330,7 +428,7 @@ describe('forge registry', function()
end) end)
it('register() with custom backend resolves URLs', function() it('register() with custom backend resolves URLs', function()
local custom = forge.gitea_backend({ local custom = forge.gitea_forge({
name = 'mygitea', name = 'mygitea',
shorthand = 'mg', shorthand = 'mg',
default_host = 'gitea.example.com', default_host = 'gitea.example.com',
@ -367,8 +465,8 @@ describe('forge registry', function()
assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args) assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args)
end) end)
it('gitea_backend() creates a working backend', function() it('gitea_forge() creates a working backend', function()
local b = forge.gitea_backend({ local b = forge.gitea_forge({
name = 'forgejo', name = 'forgejo',
shorthand = 'fj', shorthand = 'fj',
default_host = 'forgejo.example.com', default_host = 'forgejo.example.com',
@ -391,6 +489,99 @@ describe('forge registry', function()
end) end)
end) end)
describe('custom forge prefixes', function()
local config = require('pending.config')
local complete = require('pending.complete')
it('parses custom-length shorthand (3+ chars)', function()
local custom = forge.gitea_forge({
name = 'customforge',
shorthand = 'cgf',
default_host = 'custom.example.com',
})
forge.register(custom)
local ref = forge._parse_shorthand('cgf:alice/proj#99')
assert.is_not_nil(ref)
assert.equals('customforge', ref.forge)
assert.equals('alice', ref.owner)
assert.equals('proj', ref.repo)
assert.equals(99, ref.number)
end)
it('parse_ref dispatches custom-length shorthand', function()
local ref = forge.parse_ref('cgf:alice/proj#5')
assert.is_not_nil(ref)
assert.equals('customforge', ref.forge)
assert.equals(5, ref.number)
end)
it('find_refs finds custom-length shorthand', function()
local refs = forge.find_refs('Fix cgf:alice/proj#12')
assert.equals(1, #refs)
assert.equals('customforge', refs[1].ref.forge)
assert.equals(12, refs[1].ref.number)
end)
it('completion returns entries for custom backends', function()
assert.is_true(complete._is_forge_source('cgf'))
end)
it('config shorthand override re-registers backend', function()
vim.g.pending = {
forge = {
github = { shorthand = 'github' },
},
}
config.reset()
forge._reset_instances()
local ref = forge._parse_shorthand('github:user/repo#1')
assert.is_not_nil(ref)
assert.equals('github', ref.forge)
assert.equals('user', ref.owner)
assert.equals('repo', ref.repo)
assert.equals(1, ref.number)
assert.is_nil(forge._parse_shorthand('gh:user/repo#1'))
vim.g.pending = nil
config.reset()
for _, b in ipairs(forge.backends()) do
if b.name == 'github' then
b.shorthand = 'gh'
end
end
forge._reset_instances()
end)
end)
describe('is_configured', function()
it('returns false when vim.g.pending is nil', function()
vim.g.pending = nil
assert.is_false(forge.is_configured('github'))
end)
it('returns false when forge key is absent', function()
vim.g.pending = { forge = { close = true } }
assert.is_false(forge.is_configured('github'))
vim.g.pending = nil
end)
it('returns true when forge key is present', function()
vim.g.pending = { forge = { github = {} } }
assert.is_true(forge.is_configured('github'))
assert.is_false(forge.is_configured('gitlab'))
vim.g.pending = nil
end)
it('returns true for non-empty forge config', function()
vim.g.pending = { forge = { gitlab = { icon = '' } } }
assert.is_true(forge.is_configured('gitlab'))
vim.g.pending = nil
end)
end)
describe('forge diff integration', function() describe('forge diff integration', function()
local store = require('pending.store') local store = require('pending.store')
local diff = require('pending.diff') local diff = require('pending.diff')
@ -449,4 +640,19 @@ describe('forge diff integration', function()
assert.equals(1, updated._extra._forge_ref.number) assert.equals(1, updated._extra._forge_ref.number)
os.remove(tmp) os.remove(tmp)
end) end)
it('stores bare forge_ref in _extra on new task', function()
local tmp = os.tmpname()
local s = store.new(tmp)
s:load()
diff.apply({ '- [ ] Check out gh:user/repo' }, s)
local tasks = s:active_tasks()
assert.equals(1, #tasks)
assert.is_not_nil(tasks[1]._extra)
assert.is_not_nil(tasks[1]._extra._forge_ref)
assert.equals('github', tasks[1]._extra._forge_ref.forge)
assert.equals('repo', tasks[1]._extra._forge_ref.type)
assert.is_nil(tasks[1]._extra._forge_ref.number)
os.remove(tmp)
end)
end) end)

View file

@ -31,27 +31,52 @@ describe('parse', function()
it('extracts category', function() it('extracts category', function()
local desc, meta = parse.body('Buy groceries cat:Errands') local desc, meta = parse.body('Buy groceries cat:Errands')
assert.are.equal('Buy groceries', desc) assert.are.equal('Buy groceries', desc)
assert.are.equal('Errands', meta.cat) assert.are.equal('Errands', meta.category)
end) end)
it('extracts both due and cat', function() it('extracts both due and cat', function()
local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands') local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands')
assert.are.equal('Buy milk', desc) assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Errands', meta.cat) assert.are.equal('Errands', meta.category)
end) end)
it('extracts metadata in any order', function() it('extracts metadata in any order', function()
local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15') local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15')
assert.are.equal('Buy milk', desc) assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Errands', meta.cat) assert.are.equal('Errands', meta.category)
end) end)
it('stops at duplicate key', function() it('first occurrence wins for duplicate keys and warns', function()
local warnings = {}
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.WARN then
table.insert(warnings, m)
end
end
local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01')
assert.are.equal('Buy milk due:2026-03-15', desc) vim.notify = orig
assert.are.equal('2026-04-01', meta.due) assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due)
assert.are.equal(1, #warnings)
assert.truthy(warnings[1]:find('duplicate', 1, true))
end)
it('drops identical duplicate metadata tokens and warns', function()
local warnings = {}
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.WARN then
table.insert(warnings, m)
end
end
local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow')
vim.notify = orig
assert.are.equal('Buy milk', desc)
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
assert.are.equal(1, #warnings)
end) end)
it('stops at non-meta token', function() it('stops at non-meta token', function()
@ -110,6 +135,66 @@ describe('parse', function()
assert.is_nil(meta.due) assert.is_nil(meta.due)
assert.truthy(desc:find('due:garbage', 1, true)) assert.truthy(desc:find('due:garbage', 1, true))
end) end)
it('parses metadata before a forge ref', function()
local desc, meta = parse.body('Fix bug due:2026-03-15 gh:user/repo#42')
assert.are.equal('2026-03-15', meta.due)
assert.truthy(desc:find('gh:user/repo#42', 1, true))
assert.truthy(desc:find('Fix bug', 1, true))
end)
it('parses metadata after a forge ref', function()
local desc, meta = parse.body('Fix bug gh:user/repo#42 due:2026-03-15')
assert.are.equal('2026-03-15', meta.due)
assert.truthy(desc:find('gh:user/repo#42', 1, true))
assert.truthy(desc:find('Fix bug', 1, true))
end)
it('parses all metadata around forge ref', function()
local desc, meta = parse.body('Fix bug due:tomorrow gh:user/repo#42 cat:Work')
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
assert.are.equal('Work', meta.category)
assert.truthy(desc:find('gh:user/repo#42', 1, true))
end)
it('parses forge ref between metadata tokens', function()
local desc, meta = parse.body('Fix bug cat:Work gl:a/b#12 due:2026-03-15')
assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Work', meta.category)
assert.truthy(desc:find('gl:a/b#12', 1, true))
end)
it('extracts leading metadata', function()
local desc, meta = parse.body('due:2026-03-15 Fix the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('extracts metadata from the middle', function()
local desc, meta = parse.body('Fix due:2026-03-15 the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('extracts multiple metadata from any position', function()
local desc, meta = parse.body('cat:Work Fix due:2026-03-15 the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Work', meta.category)
end)
it('extracts all metadata types from mixed positions', function()
local today = os.date('*t') --[[@as osdate]]
local tomorrow = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
)
local desc, meta = parse.body('due:tomorrow cat:Work Fix the bug +!')
assert.are.equal('Fix the bug', desc)
assert.are.equal(tomorrow, meta.due)
assert.are.equal('Work', meta.category)
assert.are.equal(1, meta.priority)
end)
end) end)
describe('parse.resolve_date', function() describe('parse.resolve_date', function()
@ -400,7 +485,7 @@ describe('parse', function()
it('detects category prefix', function() it('detects category prefix', function()
local desc, meta = parse.command_add('School: Do homework') local desc, meta = parse.command_add('School: Do homework')
assert.are.equal('Do homework', desc) assert.are.equal('Do homework', desc)
assert.are.equal('School', meta.cat) assert.are.equal('School', meta.category)
end) end)
it('ignores lowercase prefix', function() it('ignores lowercase prefix', function()
@ -411,7 +496,7 @@ describe('parse', function()
it('combines category prefix with inline metadata', function() it('combines category prefix with inline metadata', function()
local desc, meta = parse.command_add('School: Do homework due:2026-03-15') local desc, meta = parse.command_add('School: Do homework due:2026-03-15')
assert.are.equal('Do homework', desc) assert.are.equal('Do homework', desc)
assert.are.equal('School', meta.cat) assert.are.equal('School', meta.category)
assert.are.equal('2026-03-15', meta.due) assert.are.equal('2026-03-15', meta.due)
end) end)
end) end)

View file

@ -8,7 +8,7 @@ describe('recur', function()
local r = recur.parse('daily') local r = recur.parse('daily')
assert.are.equal('daily', r.freq) assert.are.equal('daily', r.freq)
assert.are.equal(1, r.interval) assert.are.equal(1, r.interval)
assert.is_false(r.from_completion) assert.are.equal('scheduled', r.mode)
end) end)
it('parses weekdays', function() it('parses weekdays', function()
@ -79,7 +79,7 @@ describe('recur', function()
it('parses ! prefix as completion-based', function() it('parses ! prefix as completion-based', function()
local r = recur.parse('!weekly') local r = recur.parse('!weekly')
assert.are.equal('weekly', r.freq) assert.are.equal('weekly', r.freq)
assert.is_true(r.from_completion) assert.are.equal('completion', r.mode)
end) end)
it('parses raw RRULE fragment', function() it('parses raw RRULE fragment', function()

View file

@ -124,6 +124,100 @@ describe('sync', function()
end) end)
end) end)
describe('register_backend', function()
it('registers a custom backend', function()
pending.register_backend({ name = 'custom', pull = function() end })
local set = pending.sync_backend_set()
assert.is_true(set['custom'] == true)
assert.is_true(vim.tbl_contains(pending.sync_backends(), 'custom'))
end)
it('rejects backend without name', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({})
vim.notify = orig
assert.truthy(msg and msg:find('non%-empty'))
end)
it('rejects backend with empty name', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = '' })
vim.notify = orig
assert.truthy(msg and msg:find('non%-empty'))
end)
it('rejects duplicate of built-in backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = 'gcal' })
vim.notify = orig
assert.truthy(msg and msg:find('already exists'))
end)
it('rejects duplicate registered backend', function()
pending.register_backend({ name = 'dup_test', pull = function() end })
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = 'dup_test' })
vim.notify = orig
assert.truthy(msg and msg:find('already registered'))
end)
end)
describe('resolve_backend', function()
it('resolves built-in backend', function()
local mod = pending.resolve_backend('gcal')
assert.is_not_nil(mod)
assert.are.equal('gcal', mod.name)
end)
it('resolves registered backend', function()
local custom = { name = 'resolve_test', pull = function() end }
pending.register_backend(custom)
local mod = pending.resolve_backend('resolve_test')
assert.is_not_nil(mod)
assert.are.equal('resolve_test', mod.name)
end)
it('returns nil for unknown backend', function()
assert.is_nil(pending.resolve_backend('nonexistent_xyz'))
end)
it('dispatches command to registered backend', function()
local called = false
pending.register_backend({
name = 'cmd_test',
pull = function()
called = true
end,
})
pending.command('cmd_test pull')
assert.is_true(called)
end)
end)
describe('auto-discovery', function() describe('auto-discovery', function()
it('discovers gcal and gtasks backends', function() it('discovers gcal and gtasks backends', function()
local backends = pending.sync_backends() local backends = pending.sync_backends()

View file

@ -450,5 +450,83 @@ describe('views', function()
end end
assert.is_nil(task_meta.recur) assert.is_nil(task_meta.recur)
end) end)
it('sorts by due before priority when sort config is reordered', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'status', 'due', 'priority', 'order', 'id' } } },
}
config.reset()
s:add({ description = 'High no due', category = 'Work', priority = 2 })
s:add({ description = 'Low with due', category = 'Work', priority = 0, due = '2050-01-01' })
local lines, meta = views.priority_view(s:active_tasks())
local due_row, nodue_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Low with due') then
due_row = i
elseif lines[i]:find('High no due') then
nodue_row = i
end
end
end
assert.is_true(due_row < nodue_row)
end)
it('uses default sort when config sort is nil', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = {} },
}
config.reset()
s:add({ description = 'Low', category = 'Work', priority = 0 })
s:add({ description = 'High', category = 'Work', priority = 1 })
local lines, meta = views.priority_view(s:active_tasks())
local high_row, low_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('High') then
high_row = i
elseif lines[i]:find('Low') then
low_row = i
end
end
end
assert.is_true(high_row < low_row)
end)
it('ignores unknown sort keys with a warning', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'bogus', 'status', 'id' } } },
}
config.reset()
s:add({ description = 'A', category = 'Work' })
s:add({ description = 'B', category = 'Work' })
local lines = views.priority_view(s:active_tasks())
assert.is_true(#lines == 2)
end)
it('supports age sort key as alias for id', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { queue = { sort = { 'age' } } },
}
config.reset()
s:add({ description = 'Older', category = 'Work' })
s:add({ description = 'Newer', category = 'Work' })
local lines, meta = views.priority_view(s:active_tasks())
local older_row, newer_row
for i, m in ipairs(meta) do
if m.type == 'task' then
if lines[i]:find('Older') then
older_row = i
elseif lines[i]:find('Newer') then
newer_row = i
end
end
end
assert.is_true(older_row < newer_row)
end)
end) end)
end) end)