pending.nvim/lua/pending/init.lua
Barrett Ruth b379b3cf13 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).
2026-02-25 09:24:04 -05:00

404 lines
10 KiB
Lua

local buffer = require('pending.buffer')
local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
---@class pending.init
local M = {}
---@type pending.Task[][]
local _undo_states = {}
local UNDO_MAX = 20
---@return integer bufnr
function M.open()
local bufnr = buffer.open()
M._setup_autocmds(bufnr)
M._setup_buf_mappings(bufnr)
return bufnr
end
---@param bufnr integer
function M._setup_autocmds(bufnr)
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', {
group = group,
buffer = bufnr,
callback = function()
M._on_write(bufnr)
end,
})
vim.api.nvim_create_autocmd('BufEnter', {
group = group,
buffer = bufnr,
callback = function()
if not vim.bo[bufnr].modified then
store.load()
buffer.render(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)
vim.keymap.set('n', '<Tab>', function()
buffer.toggle_view()
end, opts)
vim.keymap.set('n', 'g?', function()
M.show_help()
end, opts)
vim.keymap.set('n', '!', function()
M.toggle_priority()
end, opts)
vim.keymap.set('n', 'D', function()
M.prompt_date()
end, opts)
vim.keymap.set('n', 'U', function()
M.undo_write()
end, opts)
vim.keymap.set('n', 'o', function()
buffer.open_line(false)
end, opts)
vim.keymap.set('n', 'O', function()
buffer.open_line(true)
end, opts)
end
---@param bufnr integer
function M._on_write(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local snapshot = store.snapshot()
table.insert(_undo_states, snapshot)
if #_undo_states > UNDO_MAX then
table.remove(_undo_states, 1)
end
diff.apply(lines)
buffer.render(bufnr)
end
function M.undo_write()
if #_undo_states == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN)
return
end
local state = table.remove(_undo_states)
store.replace_tasks(state)
store.save()
buffer.render(buffer.bufnr())
end
function M.toggle_complete()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
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 task = store.get(id)
if not task then
return
end
if task.status == 'done' then
store.update(id, { status = 'pending', ['end'] = vim.NIL })
else
store.update(id, { status = 'done' })
end
store.save()
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
function M.toggle_priority()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
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 task = store.get(id)
if not task then
return
end
local new_priority = task.priority > 0 and 0 or 1
store.update(id, { priority = new_priority })
store.save()
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
function M.prompt_date()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
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
vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input)
if not input then
return
end
local due = input ~= '' and input or nil
if due then
local resolved = parse.resolve_date(due)
if resolved then
due = resolved
elseif not due:match('^%d%d%d%d%-%d%d%-%d%d$') then
vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR)
return
end
end
store.update(id, { due = due })
store.save()
buffer.render(bufnr)
end)
end
---@param text string
function M.add(text)
if not text or text == '' then
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
return
end
store.load()
local description, metadata = parse.command_add(text)
if not description or description == '' then
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
return
end
store.add({
description = description,
category = metadata.cat,
due = metadata.due,
})
store.save()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Pending added: ' .. description)
end
function M.sync()
local ok, gcal = pcall(require, 'pending.sync.gcal')
if not ok then
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
return
end
gcal.sync()
end
---@param days? integer
function M.archive(days)
days = days or 30
local cutoff = os.time() - (days * 86400)
local tasks = store.tasks()
local archived = 0
local kept = {}
for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
if y then
local t = os.time({
year = tonumber(y) --[[@as integer]],
month = tonumber(mo) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
hour = tonumber(h) --[[@as integer]],
min = tonumber(mi) --[[@as integer]],
sec = tonumber(s) --[[@as integer]],
})
if t < cutoff then
archived = archived + 1
goto skip
end
end
end
table.insert(kept, task)
::skip::
end
store.replace_tasks(kept)
store.save()
vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
end
function M.due()
local today = os.date('%Y-%m-%d') --[[@as string]]
local bufnr = buffer.bufnr()
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
local meta = is_valid and buffer.meta() or nil
local qf_items = {}
if meta and bufnr then
for lnum, m in ipairs(meta) do
if m.type == 'task' and m.raw_due and m.status ~= 'done' and m.raw_due <= today then
local task = store.get(m.id or 0)
local label = m.raw_due < today and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, {
bufnr = bufnr,
lnum = lnum,
col = 1,
text = label .. (task and task.description or ''),
})
end
end
else
store.load()
for _, task in ipairs(store.active_tasks()) do
if task.status == 'pending' and task.due and task.due <= today then
local label = task.due < today and '[OVERDUE] ' or '[DUE] '
local text = label .. task.description
if task.category then
text = text .. ' [' .. task.category .. ']'
end
table.insert(qf_items, { text = text })
end
end
end
if #qf_items == 0 then
vim.notify('No due or overdue tasks.')
return
end
vim.fn.setqflist(qf_items, 'r')
vim.cmd('copen')
end
function M.show_help()
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local lines = {
'pending.nvim keybindings',
'',
'<CR> Toggle complete/uncomplete',
'<Tab> Switch category/priority view',
'! Toggle urgent',
'D Set due date',
'U Undo last write',
'o / O Add new task line',
'dd Delete task line (on :w)',
'p / P Paste (duplicates get new IDs)',
'zc / zo Fold/unfold category (category view)',
':w Save all changes',
'',
':Pending add <text> Quick-add task',
':Pending add Cat: <text> Quick-add with category',
':Pending due Show overdue/due qflist',
':Pending sync Push to Google Calendar',
':Pending archive [days] Purge old done tasks',
':Pending undo Undo last write',
'',
'Inline metadata (on new lines before :w):',
' ' .. dk .. ':YYYY-MM-DD Set due date',
' cat:Name Set category',
'',
'Due date input:',
' today, tomorrow, +Nd, mon-sun',
' Empty input clears due date',
'',
'Highlights:',
' PendingOverdue overdue tasks (red)',
' PendingPriority [!] urgent tasks',
'',
'Press q or <Esc> to close',
}
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
vim.bo[buf].bufhidden = 'wipe'
local width = 54
local height = #lines
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
})
vim.keymap.set('n', 'q', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
vim.keymap.set('n', '<Esc>', function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf, silent = true })
end
---@param args string
function M.command(args)
if not args or args == '' then
M.open()
return
end
local cmd, rest = args:match('^(%S+)%s*(.*)')
if cmd == 'add' then
M.add(rest)
elseif cmd == 'sync' then
M.sync()
elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil
M.archive(d)
elseif cmd == 'due' then
M.due()
elseif cmd == 'undo' then
M.undo_write()
else
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
end
end
return M