feat: add markdown detail buffer for task notes

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.
This commit is contained in:
Barrett Ruth 2026-03-13 08:18:49 -04:00
parent 0b0b64fc3d
commit d7d79b5b87
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
7 changed files with 298 additions and 0 deletions

View file

@ -584,6 +584,7 @@ local function setup_highlights()
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, 'PendingForgeClosed', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true })
end
---@return string
@ -805,4 +806,213 @@ function M.open()
return task_bufnr
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
---@param bufnr integer
---@param task pending.Task
---@return nil
local function apply_detail_extmarks(bufnr, task)
vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
local icons = config.get().icons
local parts = {}
local status_label = task.status or 'pending'
local icon_char = icons[status_label] or icons.pending
table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' })
if task.priority and task.priority > 0 then
table.insert(parts, { ' ', 'Normal' })
table.insert(
parts,
{ 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' }
)
end
local line2 = {}
if task.category then
table.insert(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' })
end
if task.due then
if #line2 > 0 then
table.insert(line2, { ' ', 'Normal' })
end
local due_label = task.due
local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)')
if y then
local t = os.time({
year = tonumber(y) --[[@as integer]],
month = tonumber(mo) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
})
due_label = os.date(config.get().date_format, t) --[[@as string]]
end
table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' })
end
if task.recur then
if #line2 > 0 then
table.insert(line2, { ' ', 'Normal' })
end
table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' })
end
if #parts > 0 then
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, {
virt_text = parts,
virt_text_pos = 'overlay',
})
end
if #line2 > 0 then
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, {
virt_text = line2,
virt_text_pos = 'overlay',
})
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,
'',
'',
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
apply_detail_extmarks(bufnr, task)
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 separator_row = 3
local cursor_row = separator_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
---@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 = nil
for i, line in ipairs(lines) do
if line == DETAIL_SEPARATOR then
sep_row = i
break
end
end
local notes_text = ''
if sep_row and 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
if notes_text == '' then
_store:update(_detail_task_id, { notes = vim.NIL })
else
_store:update(_detail_task_id, { notes = notes_text })
end
_store:save()
vim.bo[_detail_bufnr].modified = false
local updated = _store:get(_detail_task_id)
if updated then
apply_detail_extmarks(_detail_bufnr, updated)
end
end
return M