Compare commits

...
Sign in to create a new pull request.

9 commits

Author SHA1 Message Date
8433d92857 ci: format 2026-02-25 09:39:11 -05:00
fa1103ad4e doc: minify readme 2026-02-25 09:37:49 -05:00
Barrett Ruth
fbeb0e2bee
feat(buffer): open as bottom-drawer split like fugitive (#23)
* feat(buffer): open as bottom-drawer split like fugitive

Problem: :Pending replaced the current buffer, making it impossible to
view tasks alongside the file being edited. No way to close the drawer
without :q or switching buffers manually.

Solution: open the task buffer in a botright horizontal split instead of
replacing the current buffer. Track the drawer window ID so re-opening
focuses it rather than creating a second split. Set winfixheight so the
drawer keeps its height when other windows open or close. Add q/<Esc>
mappings to close the drawer, and a WinClosed autocmd to clear the
tracked window ID when the user closes it manually. Add drawer_height
config option (default 15).

* fix(buffer): default to natural split height like fugitive

Problem: hardcoded drawer_height=15 was too small and diverged from
fugitive's model. Fugitive issues a plain botright split and lets Vim's
own split rules (equalalways, winheight) divide the available space.

Solution: remove the default height so the split sizes naturally. Only
call nvim_win_set_height when the user sets drawer_height to a positive
value, preserving the opt-in customization path.
2026-02-25 09:34:17 -05:00
Barrett Ruth
5db242a9cf
refactor: adopt markdown-style checkbox buffer format (#20)
* refactor(config): change default category from Inbox to Todo

* refactor(views): adopt markdown checkbox line format

Problem: task lines used an opaque /ID/  [N] prefix format that was
hard to read and inconsistent between category and priority views.
Header lines had no visual marker distinguishing them from tasks.

Solution: render headers as '## Cat', task lines as
'/ID/- [x|!| ] description'. State encoding: [x]=done, [!]=urgent,
[ ]=pending. Both views use the same construction.

* refactor(diff): parse and reconcile markdown checkbox format

Problem: parse_buffer matched the old '  text' indent pattern and
detected headers via '^%S'. Priority was read from a '[N] ' prefix.
apply() never reconciled status changes written into the buffer.

Solution: match '- [.] text' for tasks and '^## ' for headers.
Extract state char to derive priority (! -> 1) and status (x -> done).
apply() now reconciles status from the buffer, setting/clearing 'end'
timestamps — enabling the oil-style edit-checkbox-then-:w workflow.

* refactor(buffer): update syntax, extmarks, and render for checkbox format

Problem: syntax patterns matched the old indent/[N] format; right_align
virtual text produced a broken layout in narrow windows; the done
strikethrough skipped past the '  ' indent leaving '- [x] ' unstyled;
render() added undo history entries so 'u' could undo a re-render.

Solution: update taskHeader/taskLine patterns for '## '/'- [.]'; rename
taskPriority -> taskCheckbox matching '[!]'; switch virt_text_pos to
'eol'; drop the +2 col_start offset so strikethrough covers '- [x] ';
guard nvim_buf_set_lines with undolevels=-1 so renders are not undoable.
Also fix open_line to insert '- [ ] ' and position cursor at col 6.

* refactor(init): replace multi-level priority with binary toggle

Problem: <C-a>/<C-x> overrode Vim's native number increment and the
visual g<C-a>/g<C-x> variants added complexity for marginal value.
toggle_complete() left the cursor on the wrong line after re-render.

Solution: remove change_priority/change_priority_visual; add
toggle_priority() (0<->1) mapped to '!', with cursor-follow after
render matching the pattern already used in priority toggle. Add
cursor-follow to toggle_complete() for the same reason. Update plugin
plugs (priority-up/down -> priority) and add 'due'/'undo' to the
:Pending completion list. Update help text accordingly.

* feat(buffer): reflect current view in buffer name

Problem: no way to tell at a glance which view (category vs priority)
is active — the buffer was always named 'pending://'.

Solution: update the buffer name to 'pending://category' or
'pending://priority' on every render, so the view is visible in
the statusline/tabline without any extra UI.
2026-02-24 23:21:55 -05:00
Barrett Ruth
8e16744ebe
test: add missing coverage (#19)
* test: add top-priority missing test coverage

Problem: several critical code paths had zero test coverage —
parse.resolve_date (relative date resolution), store.snapshot
(foundation of the undo stack), and the diff.apply invariant that
unchanged tasks do not get their modified timestamp bumped. The
diff.apply due/priority clearing paths were also untested.

Solution: add six targeted test blocks across parse_spec, store_spec,
and diff_spec: resolve_date happy/failure paths, parse.body with
relative due tokens, snapshot copy-semantics and deleted-task
exclusion, diff unchanged-modified invariant, due cleared on removal,
priority cleared on ! removal.

* test: add second batch of missing test coverage

Problem: six more gaps from the audit remained after the first batch —
archive persistence verification, diff modified-on-rename, parse_buffer
inline cat:/due: token parsing, and store.update immutability invariants.

Solution: add six it() blocks across archive_spec, diff_spec, and
store_spec: archive unload/reload persistence check, modified timestamp
updated on description change, inline cat: overrides header category,
inline due: token parsed from buffer line, id/entry fields immutable
under store.update, and end timestamp not overwritten on second
completion.
2026-02-24 22:33:13 -05:00
cfdffdadfe test: update priority format assertions from ! to [N]
Problem: fc4a47a changed the priority display format from '! ' to
'[N] ' in views.lua and diff.lua but left two existing test
assertions and their descriptions using the old format, causing
both to fail.

Solution: update the input line in diff parse_buffer test, update
the expected string and description names in views category_view
test, and rename the diff.apply description to match the new idiom.
2026-02-24 22:32:14 -05:00
437944d441 fix(diff): cast tonumber result to integer
Problem: LuaLS infers priority as integer from the = 0 initialiser
but tonumber returns number?, causing a cast-local-type diagnostic.

Solution: inline --[[@as integer]] cast after the tonumber call.
2026-02-24 22:32:14 -05:00
8f9052bad1 ci: format 2026-02-24 22:32:14 -05:00
7f0bd43b34 feat(buffer): preserve category fold state across re-renders
Problem: pressing :w, toggling priority, or any other operation that
calls buffer.render() reset foldlevel = 99, causing all manually
collapsed category sections to snap back open.

Solution: snapshot which categories are folded (per window) before
nvim_buf_set_lines destroys the fold tree, then restore them after
fold options are re-applied by calling normal! zc on each previously
closed header line. State persists across all render call sites
within a session.
2026-02-24 22:32:14 -05:00
17 changed files with 295 additions and 286 deletions

View file

@ -1,4 +1,4 @@
title: 'Q&A' title: "Q&A"
labels: [] labels: []
body: body:
- type: markdown - type: markdown

View file

@ -1,14 +1,13 @@
name: Bug Report name: Bug Report
description: Report a bug description: Report a bug
title: 'bug: ' title: "bug: "
labels: [bug] labels: [bug]
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Prerequisites label: Prerequisites
options: options:
- label: - label: I have searched [existing
I have searched [existing
issues](https://github.com/barrettruth/pending.nvim/issues) issues](https://github.com/barrettruth/pending.nvim/issues)
required: true required: true
- label: I have updated to the latest version - label: I have updated to the latest version
@ -16,16 +15,16 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: 'Neovim version' label: "Neovim version"
description: 'Output of `nvim --version`' description: "Output of `nvim --version`"
render: text render: text
validations: validations:
required: true required: true
- type: input - type: input
attributes: attributes:
label: 'Operating system' label: "Operating system"
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04' placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04"
validations: validations:
required: true required: true
@ -49,8 +48,8 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: 'Health check' label: "Health check"
description: 'Output of `:checkhealth task`' description: "Output of `:checkhealth task`"
render: text render: text
- type: textarea - type: textarea

View file

@ -1,14 +1,13 @@
name: Feature Request name: Feature Request
description: Suggest a feature description: Suggest a feature
title: 'feat: ' title: "feat: "
labels: [enhancement] labels: [enhancement]
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Prerequisites label: Prerequisites
options: options:
- label: - label: I have searched [existing
I have searched [existing
issues](https://github.com/barrettruth/pending.nvim/issues) issues](https://github.com/barrettruth/pending.nvim/issues)
required: true required: true

View file

@ -3,7 +3,7 @@ name: luarocks
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
jobs: jobs:
quality: quality:

143
README.md
View file

@ -2,145 +2,30 @@
Edit tasks like text. `:w` saves them. Edit tasks like text. `:w` saves them.
A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add <!-- insert preview -->
with `o`, delete with `dd`, reorder with `dd`/`p`, rename by editing. Write the
buffer and the diff is computed against a JSON store. No UI chrome, no floating
windows, no abstractions between you and your tasks.
## How it works ## Requirements
``` - Neovim 0.10+
School - (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync
! Read chapter 5 Feb 28
Submit homework Feb 25
Errands ## Installation
Buy groceries Mar 01
Clean apartment
```
Category headers sit at column 0. Tasks are indented below them. `!` marks Install with your package manager of choice or via
priority. Due dates appear as right-aligned virtual text. Done tasks get [luarocks](https://luarocks.org/modules/barrettruth/pending.nvim):
strikethrough. Everything you see is editable buffer text — the IDs are
concealed, and metadata is parsed from inline syntax on save.
## Install
``` ```
luarocks install pending.nvim luarocks install pending.nvim
``` ```
**lazy.nvim:**
```lua
{ 'barrettruth/pending.nvim' }
```
Requires Neovim 0.10+. No external dependencies for local use. Google Calendar
sync requires `curl` and `openssl`.
## Usage
`:Pending` opens the task buffer. From there, it's just vim:
| Key | Action |
| --------- | ------------------------------- |
| `o` / `O` | Add a new task |
| `dd` | Delete a task (on `:w`) |
| `p` | Paste (duplicates get new IDs) |
| `:w` | Save all changes |
| `<CR>` | Toggle complete (immediate) |
| `<Tab>` | Switch category / priority view |
| `g?` | Show keybind help |
### Inline metadata
Type metadata tokens at the end of a task line before saving:
```
Buy milk due:2026-03-15 cat:Errands
```
On `:w`, the date and category are extracted. The description becomes `Buy milk`,
the due date renders as virtual text, and the task moves under the `Errands`
header.
### Quick add
```vim
:Pending add Buy groceries due:2026-03-15
:Pending add School: Submit homework
```
### Archive
```vim
:Pending archive " purge done tasks older than 30 days
:Pending archive 7 " purge done tasks older than 7 days
```
## Configuration
No `setup()` call required. Set `vim.g.pending` before the plugin loads:
```lua
vim.g.pending = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category', -- 'category' or 'priority'
default_category = 'Inbox',
date_format = '%b %d', -- strftime format for virtual text
date_syntax = 'due', -- inline token name (e.g. 'by' for by:2026-03-15)
}
```
All fields are optional. Absent keys use the defaults shown above.
## Google Calendar sync
One-way push of tasks with due dates to a dedicated Google Calendar as all-day
events.
```lua
vim.g.pending = {
gcal = {
calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
},
}
```
```vim
:Pending sync
```
On first run, a browser window opens for OAuth consent. The refresh token is
stored at `stdpath('data')/pending/gcal_tokens.json`. Completed or deleted tasks
have their calendar events removed. Due date changes update events in place.
## Mappings
The plugin defines `<Plug>` mappings for custom keybinds:
```lua
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
```
| Plug mapping | Action |
| -------------------------- | -------------------- |
| `<Plug>(pending-open)` | Open task buffer |
| `<Plug>(pending-toggle)` | Toggle complete |
| `<Plug>(pending-view)` | Switch view |
| `<Plug>(pending-priority)` | Toggle priority flag |
| `<Plug>(pending-date)` | Prompt for due date |
## Data format
Tasks are stored as JSON at `stdpath('data')/pending/tasks.json`. The schema is
versioned and forward-compatible — unknown fields are preserved on round-trip.
## Documentation ## Documentation
```vim ```vim
:checkhealth pending :help pending.nvim
``` ```
## Acknowledgements
- [dooing](https://github.com/atiladefreitas/dooing)
- [todo-comments.nvim](https://github.com/folke/todo-comments.nvim)
- [todotxt.nvim](https://github.com/arnarg/todotxt.nvim)

View file

@ -7,11 +7,15 @@ local M = {}
---@type integer? ---@type integer?
local task_bufnr = nil local task_bufnr = nil
---@type integer?
local task_winid = nil
local task_ns = vim.api.nvim_create_namespace('pending') local task_ns = vim.api.nvim_create_namespace('pending')
---@type 'category'|'priority'|nil ---@type 'category'|'priority'|nil
local current_view = nil local current_view = nil
---@type pending.LineMeta[] ---@type pending.LineMeta[]
local _meta = {} local _meta = {}
---@type table<integer, table<string, boolean>>
local _fold_state = {}
---@return pending.LineMeta[] ---@return pending.LineMeta[]
function M.meta() function M.meta()
@ -23,11 +27,27 @@ function M.bufnr()
return task_bufnr return task_bufnr
end end
---@return integer?
function M.winid()
return task_winid
end
---@return string? ---@return string?
function M.current_view_name() function M.current_view_name()
return current_view return current_view
end end
function M.clear_winid()
task_winid = nil
end
function M.close()
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
vim.api.nvim_win_close(task_winid, false)
end
task_winid = nil
end
---@param bufnr integer ---@param bufnr integer
local function set_buf_options(bufnr) local function set_buf_options(bufnr)
vim.bo[bufnr].buftype = 'acwrite' vim.bo[bufnr].buftype = 'acwrite'
@ -48,6 +68,7 @@ local function set_win_options(winid)
vim.wo[winid].foldcolumn = '0' vim.wo[winid].foldcolumn = '0'
vim.wo[winid].spell = false vim.wo[winid].spell = false
vim.wo[winid].cursorline = true vim.wo[winid].cursorline = true
vim.wo[winid].winfixheight = true
end end
---@param bufnr integer ---@param bufnr integer
@ -56,9 +77,9 @@ local function setup_syntax(bufnr)
vim.cmd([[ vim.cmd([[
syntax clear syntax clear
syntax match taskId /^\/\d\+\// conceal syntax match taskId /^\/\d\+\// conceal
syntax match taskHeader /^\S.*$/ contains=taskId syntax match taskHeader /^## .*$/ contains=taskId
syntax match taskPriority /\[\d\+\] / contained containedin=taskLine syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
]]) ]])
end) end)
end end
@ -72,8 +93,8 @@ function M.open_line(above)
local row = vim.api.nvim_win_get_cursor(0)[1] local row = vim.api.nvim_win_get_cursor(0)[1]
local insert_row = above and (row - 1) or row local insert_row = above and (row - 1) or row
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { ' ' }) vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' })
vim.api.nvim_win_set_cursor(0, { insert_row + 1, 2 }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 })
vim.cmd('startinsert!') vim.cmd('startinsert!')
end end
@ -113,18 +134,18 @@ local function apply_extmarks(bufnr, line_meta)
if virt_text then if virt_text then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = virt_text, virt_text = virt_text,
virt_text_pos = 'right_align', virt_text_pos = 'eol',
}) })
end end
elseif m.due then elseif m.due then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = { { m.due, due_hl } }, virt_text = { { m.due, due_hl } },
virt_text_pos = 'right_align', virt_text_pos = 'eol',
}) })
end end
if m.status == 'done' then if m.status == 'done' 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 ''
local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) + 2 or 0 local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, {
end_col = #line, end_col = #line,
hl_group = 'PendingDone', hl_group = 'PendingDone',
@ -148,6 +169,48 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
end end
local function snapshot_folds(bufnr)
if current_view ~= 'category' then
return
end
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
local state = {}
vim.api.nvim_win_call(winid, function()
for lnum, m in ipairs(_meta) do
if m.type == 'header' and m.category then
if vim.fn.foldclosed(lnum) ~= -1 then
state[m.category] = true
end
end
end
end)
_fold_state[winid] = state
end
end
local function restore_folds(bufnr)
if current_view ~= 'category' then
return
end
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
local state = _fold_state[winid]
if state and next(state) ~= nil then
vim.api.nvim_win_call(winid, function()
vim.cmd('normal! zx')
local saved = vim.api.nvim_win_get_cursor(0)
for lnum, m in ipairs(_meta) do
if m.type == 'header' and m.category and state[m.category] then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
vim.cmd('normal! zc')
end
end
vim.api.nvim_win_set_cursor(0, saved)
end)
_fold_state[winid] = nil
end
end
end
---@param bufnr? integer ---@param bufnr? integer
function M.render(bufnr) function M.render(bufnr)
bufnr = bufnr or task_bufnr bufnr = bufnr or task_bufnr
@ -156,6 +219,7 @@ function M.render(bufnr)
end end
current_view = current_view or config.get().default_view current_view = current_view or config.get().default_view
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view)
local tasks = store.active_tasks() local tasks = store.active_tasks()
local lines, line_meta local lines, line_meta
@ -167,9 +231,13 @@ function M.render(bufnr)
_meta = line_meta _meta = line_meta
snapshot_folds(bufnr)
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
local saved = vim.bo[bufnr].undolevels
vim.bo[bufnr].undolevels = -1
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modified = false vim.bo[bufnr].modified = false
vim.bo[bufnr].undolevels = saved
setup_syntax(bufnr) setup_syntax(bufnr)
apply_extmarks(bufnr, line_meta) apply_extmarks(bufnr, line_meta)
@ -185,6 +253,7 @@ function M.render(bufnr)
vim.wo[winid].foldenable = false vim.wo[winid].foldenable = false
end end
end end
restore_folds(bufnr)
end end
function M.toggle_view() function M.toggle_view()
@ -201,25 +270,25 @@ function M.open()
setup_highlights() setup_highlights()
store.load() store.load()
if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then if task_winid and vim.api.nvim_win_is_valid(task_winid) then
local wins = vim.fn.win_findbuf(task_bufnr) vim.api.nvim_set_current_win(task_winid)
if #wins > 0 then
vim.api.nvim_set_current_win(wins[1])
M.render(task_bufnr)
return task_bufnr
end
vim.api.nvim_set_current_buf(task_bufnr)
set_win_options(vim.api.nvim_get_current_win())
M.render(task_bufnr) M.render(task_bufnr)
return task_bufnr return task_bufnr --[[@as integer]]
end end
task_bufnr = vim.api.nvim_create_buf(true, false) if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then
vim.api.nvim_buf_set_name(task_bufnr, 'pending://') task_bufnr = vim.api.nvim_create_buf(true, false)
set_buf_options(task_bufnr)
end
set_buf_options(task_bufnr) vim.cmd('botright new')
vim.api.nvim_set_current_buf(task_bufnr) task_winid = vim.api.nvim_get_current_win()
set_win_options(vim.api.nvim_get_current_win()) vim.api.nvim_win_set_buf(task_winid, task_bufnr)
local h = config.get().drawer_height
if h and h > 0 then
vim.api.nvim_win_set_height(task_winid, h)
end
set_win_options(task_winid)
M.render(task_bufnr) M.render(task_bufnr)

View file

@ -9,6 +9,7 @@
---@field date_format string ---@field date_format string
---@field date_syntax string ---@field date_syntax string
---@field category_order? string[] ---@field category_order? string[]
---@field drawer_height? integer
---@field gcal? pending.GcalConfig ---@field gcal? pending.GcalConfig
---@class pending.config ---@class pending.config
@ -18,7 +19,7 @@ local M = {}
local defaults = { local defaults = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category', default_view = 'category',
default_category = 'Inbox', default_category = 'Todo',
date_format = '%b %d', date_format = '%b %d',
date_syntax = 'due', date_syntax = 'due',
category_order = {}, category_order = {},

View file

@ -7,6 +7,7 @@ local store = require('pending.store')
---@field id? integer ---@field id? integer
---@field description? string ---@field description? string
---@field priority? integer ---@field priority? integer
---@field status? string
---@field category? string ---@field category? string
---@field due? string ---@field due? string
---@field lnum integer ---@field lnum integer
@ -26,20 +27,17 @@ function M.parse_buffer(lines)
local current_category = nil local current_category = nil
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
local id, body = line:match('^/(%d+)/( .+)$') local id, body = line:match('^/(%d+)/(- %[.%] .*)$')
if not id then if not id then
body = line:match('^( .+)$') body = line:match('^(- %[.%] .*)$')
end end
if line == '' then if line == '' then
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 prio_str = stripped:match('^%[(%d+)%] ') local state_char = body:match('^- %[(.-)%]') or ' '
local priority = 0 local priority = state_char == '!' and 1 or 0
if prio_str then local status = state_char == 'x' and 'done' or 'pending'
priority = tonumber(prio_str)
stripped = stripped:sub(#prio_str + 4)
end
local description, metadata = parse.body(stripped) local description, metadata = parse.body(stripped)
if description and description ~= '' then if description and description ~= '' then
table.insert(result, { table.insert(result, {
@ -47,14 +45,15 @@ function M.parse_buffer(lines)
id = id and tonumber(id) or nil, id = id and tonumber(id) or nil,
description = description, description = description,
priority = priority, priority = priority,
status = status,
category = metadata.cat or current_category or config.get().default_category, category = metadata.cat or current_category or config.get().default_category,
due = metadata.due, due = metadata.due,
lnum = i, lnum = i,
}) })
end end
elseif line:match('^%S') then elseif line:match('^## (.+)$') then
current_category = line current_category = line:match('^## (.+)$')
table.insert(result, { type = 'header', category = line, lnum = i }) table.insert(result, { type = 'header', category = current_category, lnum = i })
end end
end end
@ -113,6 +112,15 @@ function M.apply(lines)
task.due = entry.due task.due = entry.due
changed = true changed = true
end end
if entry.status and task.status ~= entry.status then
task.status = entry.status
if entry.status == 'done' then
task['end'] = now
else
task['end'] = nil
end
changed = true
end
if task.order ~= order_counter then if task.order ~= order_counter then
task.order = order_counter task.order = order_counter
changed = true changed = true

View file

@ -38,11 +38,25 @@ function M._setup_autocmds(bufnr)
end end
end, end,
}) })
vim.api.nvim_create_autocmd('WinClosed', {
group = group,
callback = function(ev)
if tonumber(ev.match) == buffer.winid() then
buffer.clear_winid()
end
end,
})
end end
---@param bufnr integer ---@param bufnr integer
function M._setup_buf_mappings(bufnr) function M._setup_buf_mappings(bufnr)
local opts = { buffer = bufnr, silent = true } local opts = { buffer = bufnr, silent = true }
vim.keymap.set('n', 'q', function()
buffer.close()
end, opts)
vim.keymap.set('n', '<Esc>', function()
buffer.close()
end, opts)
vim.keymap.set('n', '<CR>', function() vim.keymap.set('n', '<CR>', function()
M.toggle_complete() M.toggle_complete()
end, opts) end, opts)
@ -52,17 +66,8 @@ function M._setup_buf_mappings(bufnr)
vim.keymap.set('n', 'g?', function() vim.keymap.set('n', 'g?', function()
M.show_help() M.show_help()
end, opts) end, opts)
vim.keymap.set('n', '<C-a>', function() vim.keymap.set('n', '!', function()
M.change_priority(1) M.toggle_priority()
end, opts)
vim.keymap.set('n', '<C-x>', function()
M.change_priority(-1)
end, opts)
vim.keymap.set('v', 'g<C-a>', function()
M.change_priority_visual(1)
end, opts)
vim.keymap.set('v', 'g<C-x>', function()
M.change_priority_visual(-1)
end, opts) end, opts)
vim.keymap.set('n', 'D', function() vim.keymap.set('n', 'D', function()
M.prompt_date() M.prompt_date()
@ -126,10 +131,15 @@ function M.toggle_complete()
end end
store.save() store.save()
buffer.render(bufnr) buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break
end
end
end end
---@param delta integer function M.toggle_priority()
function M.change_priority(delta)
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
return return
@ -147,7 +157,7 @@ function M.change_priority(delta)
if not task then if not task then
return return
end end
local new_priority = math.max(0, task.priority + delta) local new_priority = task.priority > 0 and 0 or 1
store.update(id, { priority = new_priority }) store.update(id, { priority = new_priority })
store.save() store.save()
buffer.render(bufnr) buffer.render(bufnr)
@ -159,33 +169,6 @@ function M.change_priority(delta)
end end
end end
---@param delta integer
function M.change_priority_visual(delta)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local start_row = vim.fn.line("'<")
local end_row = vim.fn.line("'>")
local meta = buffer.meta()
local changed = false
for row = start_row, end_row do
local m = meta[row]
if m and m.type == 'task' and m.id then
local task = store.get(m.id)
if task then
local new_priority = math.max(0, task.priority + delta)
store.update(m.id, { priority = new_priority })
changed = true
end
end
end
if changed then
store.save()
buffer.render(bufnr)
end
end
function M.prompt_date() function M.prompt_date()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
@ -342,10 +325,7 @@ function M.show_help()
'', '',
'<CR> Toggle complete/uncomplete', '<CR> Toggle complete/uncomplete',
'<Tab> Switch category/priority view', '<Tab> Switch category/priority view',
'<C-a> Raise priority level', '! Toggle urgent',
'<C-x> Lower priority level',
'g<C-a> Raise priority for visual selection',
'g<C-x> Lower priority for visual selection',
'D Set due date', 'D Set due date',
'U Undo last write', 'U Undo last write',
'o / O Add new task line', 'o / O Add new task line',
@ -371,7 +351,7 @@ function M.show_help()
'', '',
'Highlights:', 'Highlights:',
' PendingOverdue overdue tasks (red)', ' PendingOverdue overdue tasks (red)',
' PendingPriority [N] priority prefix', ' PendingPriority [!] urgent tasks',
'', '',
'Press q or <Esc> to close', 'Press q or <Esc> to close',
} }

View file

@ -125,7 +125,7 @@ function M.category_view(tasks)
table.insert(lines, '') table.insert(lines, '')
table.insert(meta, { type = 'blank' }) table.insert(meta, { type = 'blank' })
end end
table.insert(lines, cat) table.insert(lines, '## ' .. cat)
table.insert(meta, { type = 'header', category = cat }) table.insert(meta, { type = 'header', category = cat })
local all = {} local all = {}
@ -138,9 +138,8 @@ function M.category_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local indent = ' ' local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
local prio = task.priority > 0 and ('[' .. task.priority .. '] ') or '' local line = prefix .. '- [' .. state .. '] ' .. task.description
local line = prefix .. indent .. prio .. task.description
table.insert(lines, line) table.insert(lines, line)
table.insert(meta, { table.insert(meta, {
type = 'task', type = 'task',
@ -189,9 +188,8 @@ 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 indent = ' ' local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
local prio = task.priority == 1 and '! ' or '' local line = prefix .. '- [' .. state .. '] ' .. task.description
local line = prefix .. indent .. prio .. task.description
table.insert(lines, line) table.insert(lines, line)
table.insert(meta, { table.insert(meta, {
type = 'task', type = 'task',

View file

@ -8,7 +8,7 @@ vim.api.nvim_create_user_command('Pending', function(opts)
end, { end, {
nargs = '*', nargs = '*',
complete = function(arg_lead, cmd_line) complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'sync', 'archive' } local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
if not cmd_line:match('^Pending%s+%S') then if not cmd_line:match('^Pending%s+%S') then
return vim.tbl_filter(function(s) return vim.tbl_filter(function(s)
return s:find(arg_lead, 1, true) == 1 return s:find(arg_lead, 1, true) == 1
@ -30,12 +30,8 @@ vim.keymap.set('n', '<Plug>(pending-view)', function()
require('pending.buffer').toggle_view() require('pending.buffer').toggle_view()
end) end)
vim.keymap.set('n', '<Plug>(pending-priority-up)', function() vim.keymap.set('n', '<Plug>(pending-priority)', function()
require('pending').change_priority(1) require('pending').toggle_priority()
end)
vim.keymap.set('n', '<Plug>(pending-priority-down)', function()
require('pending').change_priority(-1)
end) end)
vim.keymap.set('n', '<Plug>(pending-date)', function() vim.keymap.set('n', '<Plug>(pending-date)', function()

View file

@ -128,4 +128,13 @@ describe('archive', function()
assert.is_true(descs['Keep pending']) assert.is_true(descs['Keep pending'])
assert.is_true(descs['Keep recent done']) assert.is_true(descs['Keep recent done'])
end) end)
it('persists archived tasks to disk after unload/reload', function()
local t = store.add({ description = 'Archived task' })
store.update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
pending.archive()
store.unload()
store.load()
assert.are.equal(0, #store.active_tasks())
end)
end) end)

View file

@ -25,12 +25,12 @@ describe('diff', function()
describe('parse_buffer', function() describe('parse_buffer', function()
it('parses headers and tasks', function() it('parses headers and tasks', function()
local lines = { local lines = {
'School', '## School',
'/1/ Do homework', '/1/- [ ] Do homework',
'/2/ ! Read chapter 5', '/2/- [!] Read chapter 5',
'', '',
'Errands', '## Errands',
'/3/ Buy groceries', '/3/- [ ] Buy groceries',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal(6, #result) assert.are.equal(6, #result)
@ -48,8 +48,8 @@ describe('diff', function()
it('handles new tasks without ids', function() it('handles new tasks without ids', function()
local lines = { local lines = {
'Inbox', '## Inbox',
' New task here', '- [ ] New task here',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal(2, #result) assert.are.equal(2, #result)
@ -57,14 +57,36 @@ describe('diff', function()
assert.is_nil(result[2].id) assert.is_nil(result[2].id)
assert.are.equal('New task here', result[2].description) assert.are.equal('New task here', result[2].description)
end) end)
it('inline cat: token overrides header category', function()
local lines = {
'## Inbox',
'/1/- [ ] Buy milk cat:Work',
}
local result = diff.parse_buffer(lines)
assert.are.equal(2, #result)
assert.are.equal('task', result[2].type)
assert.are.equal('Work', result[2].category)
end)
it('inline due: token is parsed', function()
local lines = {
'## Inbox',
'/1/- [ ] Buy milk due:2026-03-15',
}
local result = diff.parse_buffer(lines)
assert.are.equal(2, #result)
assert.are.equal('task', result[2].type)
assert.are.equal('2026-03-15', result[2].due)
end)
end) end)
describe('apply', function() describe('apply', function()
it('creates new tasks from buffer lines', function() it('creates new tasks from buffer lines', function()
local lines = { local lines = {
'Inbox', '## Inbox',
' First task', '- [ ] First task',
' Second task', '- [ ] Second task',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -80,8 +102,8 @@ describe('diff', function()
store.add({ description = 'Delete me' }) store.add({ description = 'Delete me' })
store.save() store.save()
local lines = { local lines = {
'Inbox', '## Inbox',
'/1/ Keep me', '/1/- [ ] Keep me',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -97,8 +119,8 @@ describe('diff', function()
store.add({ description = 'Original' }) store.add({ description = 'Original' })
store.save() store.save()
local lines = { local lines = {
'Inbox', '## Inbox',
'/1/ Renamed', '/1/- [ ] Renamed',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -107,13 +129,29 @@ describe('diff', function()
assert.are.equal('Renamed', task.description) assert.are.equal('Renamed', task.description)
end) end)
it('updates modified when description is renamed', function()
local t = store.add({ description = 'Original', category = 'Inbox' })
t.modified = '2020-01-01T00:00:00Z'
store.save()
local lines = {
'## Inbox',
'/1/- [ ] Renamed',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('Renamed', task.description)
assert.is_not.equal('2020-01-01T00:00:00Z', task.modified)
end)
it('handles duplicate ids as copies', function() it('handles duplicate ids as copies', function()
store.add({ description = 'Original' }) store.add({ description = 'Original' })
store.save() store.save()
local lines = { local lines = {
'Inbox', '## Inbox',
'/1/ Original', '/1/- [ ] Original',
'/1/ Copy of original', '/1/- [ ] Copy of original',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -126,8 +164,8 @@ describe('diff', function()
store.add({ description = 'Moving task', category = 'Inbox' }) store.add({ description = 'Moving task', category = 'Inbox' })
store.save() store.save()
local lines = { local lines = {
'Work', '## Work',
'/1/ Moving task', '/1/- [ ] Moving task',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -140,8 +178,8 @@ describe('diff', function()
store.add({ description = 'Stable task', category = 'Inbox' }) store.add({ description = 'Stable task', category = 'Inbox' })
store.save() store.save()
local lines = { local lines = {
'Inbox', '## Inbox',
'/1/ Stable task', '/1/- [ ] Stable task',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -158,8 +196,8 @@ describe('diff', function()
store.add({ description = 'Pay bill', due = '2026-03-15' }) store.add({ description = 'Pay bill', due = '2026-03-15' })
store.save() store.save()
local lines = { local lines = {
'Inbox', '## Inbox',
'/1/ Pay bill', '/1/- [ ] Pay bill',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -168,12 +206,12 @@ describe('diff', function()
assert.is_nil(task.due) assert.is_nil(task.due)
end) end)
it('clears priority when ! is removed from buffer line', function() it('clears priority when [N] is removed from buffer line', function()
store.add({ description = 'Task name', priority = 1 }) store.add({ description = 'Task name', priority = 1 })
store.save() store.save()
local lines = { local lines = {
'Inbox', '## Inbox',
'/1/ Task name', '/1/- [ ] Task name',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()

View file

@ -96,7 +96,10 @@ describe('parse', function()
it('resolves due:+2d to today plus 2 days', function() it('resolves due:+2d to today plus 2 days', function()
local today = os.date('*t') --[[@as osdate]] 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 + 2 })) local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 2 })
)
local desc, meta = parse.body('Task due:+2d') local desc, meta = parse.body('Task due:+2d')
assert.are.equal('Task', desc) assert.are.equal('Task', desc)
assert.are.equal(expected, meta.due) assert.are.equal(expected, meta.due)
@ -123,7 +126,10 @@ describe('parse', function()
it("returns today + 3 days for '+3d'", function() it("returns today + 3 days for '+3d'", function()
local today = os.date('*t') --[[@as osdate]] 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 + 3 })) local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 3 })
)
local result = parse.resolve_date('+3d') local result = parse.resolve_date('+3d')
assert.are.equal(expected, result) assert.are.equal(expected, result)
end) end)
@ -139,12 +145,12 @@ describe('parse', function()
assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$')) assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$'))
end) end)
it("returns nil for garbage input", function() it('returns nil for garbage input', function()
local result = parse.resolve_date('notadate') local result = parse.resolve_date('notadate')
assert.is_nil(result) assert.is_nil(result)
end) end)
it("returns nil for empty string", function() it('returns nil for empty string', function()
local result = parse.resolve_date('') local result = parse.resolve_date('')
assert.is_nil(result) assert.is_nil(result)
end) end)

View file

@ -92,7 +92,7 @@ describe('store', function()
assert.are.equal(1, t1.id) assert.are.equal(1, t1.id)
assert.are.equal(2, t2.id) assert.are.equal(2, t2.id)
assert.are.equal('pending', t1.status) assert.are.equal('pending', t1.status)
assert.are.equal('Inbox', t1.category) assert.are.equal('Todo', t1.category)
end) end)
it('uses provided category', function() it('uses provided category', function()
@ -121,6 +121,27 @@ describe('store', function()
local updated = store.get(t.id) local updated = store.get(t.id)
assert.is_not_nil(updated['end']) assert.is_not_nil(updated['end'])
end) end)
it('does not overwrite id or entry', function()
store.load()
local t = store.add({ description = 'Immutable fields' })
local original_id = t.id
local original_entry = t.entry
store.update(t.id, { id = 999, entry = 'x' })
local updated = store.get(original_id)
assert.are.equal(original_id, updated.id)
assert.are.equal(original_entry, updated.entry)
end)
it('does not overwrite end on second completion', function()
store.load()
local t = store.add({ description = 'Complete twice' })
store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
local first_end = store.get(t.id)['end']
store.update(t.id, { status = 'done' })
local task = store.get(t.id)
assert.are.equal(first_end, task['end'])
end)
end) end)
describe('delete', function() describe('delete', function()

View file

@ -27,7 +27,7 @@ describe('views', function()
store.add({ description = 'Task A', category = 'Work' }) store.add({ description = 'Task A', category = 'Work' })
store.add({ description = 'Task B', category = 'Work' }) store.add({ description = 'Task B', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(store.active_tasks())
assert.are.equal('Work', lines[1]) assert.are.equal('## Work', lines[1])
assert.are.equal('header', meta[1].type) assert.are.equal('header', meta[1].type)
assert.is_true(lines[2]:find('Task A') ~= nil) assert.is_true(lines[2]:find('Task A') ~= nil)
assert.is_true(lines[3]:find('Task B') ~= nil) assert.is_true(lines[3]:find('Task B') ~= nil)
@ -113,10 +113,10 @@ describe('views', function()
task_line = lines[i] task_line = lines[i]
end end
end end
assert.are.equal('/1/ My task', task_line) assert.are.equal('/1/- [ ] My task', task_line)
end) end)
it('formats priority task lines as /ID/ ! description', function() it('formats priority task lines as /ID/- [!] description', function()
store.add({ description = 'Important', category = 'Inbox', priority = 1 }) store.add({ description = 'Important', category = 'Inbox', priority = 1 })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(store.active_tasks())
local task_line local task_line
@ -125,7 +125,7 @@ describe('views', function()
task_line = lines[i] task_line = lines[i]
end end
end end
assert.are.equal('/1/ ! Important', task_line) assert.are.equal('/1/- [!] Important', task_line)
end) end)
it('sets LineMeta type=header for header lines with correct category', function() it('sets LineMeta type=header for header lines with correct category', function()
@ -220,8 +220,8 @@ describe('views', function()
end end
end end
end end
assert.are.equal('Work', first_header) assert.are.equal('## Work', first_header)
assert.are.equal('Inbox', second_header) assert.are.equal('## Inbox', second_header)
end) end)
it('appends categories not in category_order after ordered ones', function() it('appends categories not in category_order after ordered ones', function()
@ -236,8 +236,8 @@ describe('views', function()
table.insert(headers, lines[i]) table.insert(headers, lines[i])
end end
end end
assert.are.equal('Work', headers[1]) assert.are.equal('## Work', headers[1])
assert.are.equal('Errands', headers[2]) assert.are.equal('## Errands', headers[2])
end) end)
it('preserves insertion order when category_order is empty', function() it('preserves insertion order when category_order is empty', function()
@ -250,8 +250,8 @@ describe('views', function()
table.insert(headers, lines[i]) table.insert(headers, lines[i])
end end
end end
assert.are.equal('Alpha', headers[1]) assert.are.equal('## Alpha', headers[1])
assert.are.equal('Beta', headers[2]) assert.are.equal('## Beta', headers[2])
end) end)
end) end)
@ -325,10 +325,10 @@ describe('views', function()
assert.is_true(earlier_row < later_row) assert.is_true(earlier_row < later_row)
end) end)
it('formats task lines as /ID/ description', function() it('formats task lines as /ID/- [ ] description', function()
store.add({ description = 'My task', category = 'Inbox' }) store.add({ description = 'My task', category = 'Inbox' })
local lines, _ = views.priority_view(store.active_tasks()) local lines, _ = views.priority_view(store.active_tasks())
assert.are.equal('/1/ My task', lines[1]) assert.are.equal('/1/- [ ] My task', lines[1])
end) end)
it('sets show_category=true for all task meta entries', function() it('sets show_category=true for all task meta entries', function()

View file

@ -3,12 +3,12 @@ if exists('b:current_syntax')
endif endif
syntax match taskId /^\/\d\+\// conceal syntax match taskId /^\/\d\+\// conceal
syntax match taskHeader /^\S.*$/ contains=taskId syntax match taskHeader /^## .*$/ contains=taskId
syntax match taskPriority /!\ze / contained syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
highlight default link taskHeader PendingHeader highlight default link taskHeader PendingHeader
highlight default link taskPriority PendingPriority highlight default link taskCheckbox PendingPriority
highlight default link taskLine Normal highlight default link taskLine Normal
let b:current_syntax = 'task' let b:current_syntax = 'task'