Compare commits

..

5 commits

Author SHA1 Message Date
ce00f28c96 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.
2026-02-24 23:15:02 -05:00
d243d5897a 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.
2026-02-24 23:14:53 -05:00
fe2ee47b5e 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.
2026-02-24 23:14:41 -05:00
afb9e65f8d 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.
2026-02-24 23:14:32 -05:00
4c8944c5ee refactor(config): change default category from Inbox to Todo 2026-02-24 23:14:23 -05:00
12 changed files with 163 additions and 201 deletions

View file

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

View file

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

View file

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

View file

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

143
README.md
View file

@ -2,30 +2,145 @@
Edit tasks like text. `:w` saves them.
<!-- insert preview -->
A buffer-centric task manager for Neovim. Tasks live in a plain buffer — add
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.
## Requirements
## How it works
- Neovim 0.10+
- (Optionally) `curl` and `openssl` for Google Calendar and Google Task sync
```
School
! Read chapter 5 Feb 28
Submit homework Feb 25
## Installation
Errands
Buy groceries Mar 01
Clean apartment
```
Install with your package manager of choice or via
[luarocks](https://luarocks.org/modules/barrettruth/pending.nvim):
Category headers sit at column 0. Tasks are indented below them. `!` marks
priority. Due dates appear as right-aligned virtual text. Done tasks get
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
```
**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
```vim
:help pending.nvim
:checkhealth pending
```
## 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,15 +7,11 @@ local M = {}
---@type integer?
local task_bufnr = nil
---@type integer?
local task_winid = nil
local task_ns = vim.api.nvim_create_namespace('pending')
---@type 'category'|'priority'|nil
local current_view = nil
---@type pending.LineMeta[]
local _meta = {}
---@type table<integer, table<string, boolean>>
local _fold_state = {}
---@return pending.LineMeta[]
function M.meta()
@ -27,27 +23,11 @@ function M.bufnr()
return task_bufnr
end
---@return integer?
function M.winid()
return task_winid
end
---@return string?
function M.current_view_name()
return current_view
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
local function set_buf_options(bufnr)
vim.bo[bufnr].buftype = 'acwrite'
@ -68,7 +48,6 @@ local function set_win_options(winid)
vim.wo[winid].foldcolumn = '0'
vim.wo[winid].spell = false
vim.wo[winid].cursorline = true
vim.wo[winid].winfixheight = true
end
---@param bufnr integer
@ -169,48 +148,6 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
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
function M.render(bufnr)
bufnr = bufnr or task_bufnr
@ -219,7 +156,6 @@ function M.render(bufnr)
end
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 lines, line_meta
@ -231,7 +167,6 @@ function M.render(bufnr)
_meta = line_meta
snapshot_folds(bufnr)
vim.bo[bufnr].modifiable = true
local saved = vim.bo[bufnr].undolevels
vim.bo[bufnr].undolevels = -1
@ -253,7 +188,6 @@ function M.render(bufnr)
vim.wo[winid].foldenable = false
end
end
restore_folds(bufnr)
end
function M.toggle_view()
@ -270,25 +204,25 @@ function M.open()
setup_highlights()
store.load()
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
vim.api.nvim_set_current_win(task_winid)
if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then
local wins = vim.fn.win_findbuf(task_bufnr)
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)
return task_bufnr --[[@as integer]]
return task_bufnr
end
if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then
task_bufnr = vim.api.nvim_create_buf(true, false)
set_buf_options(task_bufnr)
end
task_bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_buf_set_name(task_bufnr, 'pending://')
vim.cmd('botright new')
task_winid = 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)
set_buf_options(task_bufnr)
vim.api.nvim_set_current_buf(task_bufnr)
set_win_options(vim.api.nvim_get_current_win())
M.render(task_bufnr)

View file

@ -9,7 +9,6 @@
---@field date_format string
---@field date_syntax string
---@field category_order? string[]
---@field drawer_height? integer
---@field gcal? pending.GcalConfig
---@class pending.config

View file

@ -38,25 +38,11 @@ function M._setup_autocmds(bufnr)
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
---@param bufnr integer
function M._setup_buf_mappings(bufnr)
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()
M.toggle_complete()
end, opts)

View file

@ -128,13 +128,4 @@ describe('archive', function()
assert.is_true(descs['Keep pending'])
assert.is_true(descs['Keep recent done'])
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)

View file

@ -57,28 +57,6 @@ describe('diff', function()
assert.is_nil(result[2].id)
assert.are.equal('New task here', result[2].description)
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)
describe('apply', function()
@ -129,22 +107,6 @@ describe('diff', function()
assert.are.equal('Renamed', task.description)
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()
store.add({ description = 'Original' })
store.save()
@ -206,7 +168,7 @@ describe('diff', function()
assert.is_nil(task.due)
end)
it('clears priority when [N] is removed from buffer line', function()
it('clears priority when ! is removed from buffer line', function()
store.add({ description = 'Task name', priority = 1 })
store.save()
local lines = {

View file

@ -96,10 +96,7 @@ describe('parse', function()
it('resolves due:+2d to today plus 2 days', function()
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')
assert.are.equal('Task', desc)
assert.are.equal(expected, meta.due)
@ -126,10 +123,7 @@ describe('parse', function()
it("returns today + 3 days for '+3d'", function()
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')
assert.are.equal(expected, result)
end)
@ -145,12 +139,12 @@ describe('parse', function()
assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$'))
end)
it('returns nil for garbage input', function()
it("returns nil for garbage input", function()
local result = parse.resolve_date('notadate')
assert.is_nil(result)
end)
it('returns nil for empty string', function()
it("returns nil for empty string", function()
local result = parse.resolve_date('')
assert.is_nil(result)
end)

View file

@ -121,27 +121,6 @@ describe('store', function()
local updated = store.get(t.id)
assert.is_not_nil(updated['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)
describe('delete', function()