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:
parent
0b0b64fc3d
commit
d7d79b5b87
7 changed files with 298 additions and 0 deletions
|
|
@ -348,6 +348,7 @@ Default buffer-local keys: ~
|
|||
`gw` Toggle work-in-progress status (`wip`)
|
||||
`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`)
|
||||
`<Tab>` Switch between category / queue view (`view`)
|
||||
`gz` Undo the last `:w` save (`undo`)
|
||||
|
|
@ -487,6 +488,12 @@ old keys to `false`: >lua
|
|||
Decrement the priority level for the task under the cursor, clamped
|
||||
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)
|
||||
Insert a correctly-formatted blank task line below the cursor.
|
||||
|
|
@ -557,6 +564,29 @@ Queue view: ~ *pending-view-queue*
|
|||
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*
|
||||
|
||||
|
|
@ -789,6 +819,7 @@ loads: >lua
|
|||
wip = 'gw',
|
||||
blocked = 'gb',
|
||||
cancelled = 'g/',
|
||||
edit_notes = 'ge',
|
||||
},
|
||||
sync = {
|
||||
gcal = {},
|
||||
|
|
@ -1651,6 +1682,7 @@ Task fields: ~
|
|||
{entry} (string) ISO 8601 UTC timestamp of creation.
|
||||
{modified} (string) ISO 8601 UTC timestamp of last modification.
|
||||
{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.
|
||||
|
||||
Any field not in the list above is preserved in `_extra` and written back on
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@
|
|||
---@field priority_up_visual? string|false
|
||||
---@field priority_down_visual? string|false
|
||||
---@field cancelled? string|false
|
||||
---@field edit_notes? string|false
|
||||
|
||||
---@class pending.CategoryViewConfig
|
||||
---@field order? string[]
|
||||
|
|
@ -163,6 +164,7 @@ local defaults = {
|
|||
wip = 'gw',
|
||||
blocked = 'gb',
|
||||
cancelled = 'g/',
|
||||
edit_notes = 'ge',
|
||||
priority_up = '<C-a>',
|
||||
priority_down = '<C-x>',
|
||||
priority_up_visual = 'g<C-a>',
|
||||
|
|
|
|||
|
|
@ -401,6 +401,9 @@ function M._setup_buf_mappings(bufnr)
|
|||
open_line_above = function()
|
||||
buffer.open_line(true)
|
||||
end,
|
||||
edit_notes = function()
|
||||
M.open_detail()
|
||||
end,
|
||||
}
|
||||
|
||||
for name, fn in pairs(actions) do
|
||||
|
|
@ -888,6 +891,46 @@ function M.toggle_status(target_status)
|
|||
end
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.open_detail()
|
||||
local bufnr = buffer.bufnr()
|
||||
if not bufnr then
|
||||
return
|
||||
end
|
||||
if not require_saved() 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 detail_bufnr = buffer.open_detail(id)
|
||||
if not detail_bufnr then
|
||||
return
|
||||
end
|
||||
|
||||
local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true })
|
||||
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
||||
group = group,
|
||||
buffer = detail_bufnr,
|
||||
callback = function()
|
||||
buffer.save_detail()
|
||||
end,
|
||||
})
|
||||
|
||||
local km = require('pending.config').get().keymaps
|
||||
vim.keymap.set('n', km.close or 'q', function()
|
||||
vim.api.nvim_del_augroup_by_name('PendingDetail')
|
||||
buffer.close_detail()
|
||||
end, { buffer = detail_bufnr })
|
||||
end
|
||||
|
||||
---@param direction 'up'|'down'
|
||||
---@return nil
|
||||
function M.move_task(direction)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ local config = require('pending.config')
|
|||
---@field entry string
|
||||
---@field modified string
|
||||
---@field end? string
|
||||
---@field notes? string
|
||||
---@field order integer
|
||||
---@field _extra? pending.TaskExtra
|
||||
|
||||
|
|
@ -93,6 +94,7 @@ local known_fields = {
|
|||
entry = true,
|
||||
modified = true,
|
||||
['end'] = true,
|
||||
notes = true,
|
||||
order = true,
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +126,9 @@ local function task_to_table(task)
|
|||
if task['end'] then
|
||||
t['end'] = task['end']
|
||||
end
|
||||
if task.notes then
|
||||
t.notes = task.notes
|
||||
end
|
||||
if task.order and task.order ~= 0 then
|
||||
t.order = task.order
|
||||
end
|
||||
|
|
@ -150,6 +155,7 @@ local function table_to_task(t)
|
|||
entry = t.entry,
|
||||
modified = t.modified,
|
||||
['end'] = t['end'],
|
||||
notes = t.notes,
|
||||
order = t.order or 0,
|
||||
_extra = {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ function M.priority_view(tasks)
|
|||
forge_ref = task._extra and task._extra._forge_ref or nil,
|
||||
forge_cache = task._extra and task._extra._forge_cache or nil,
|
||||
forge_spans = compute_forge_spans(task, prefix_len),
|
||||
has_notes = task.notes ~= nil and task.notes ~= '',
|
||||
})
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -407,6 +407,10 @@ 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()
|
||||
require('pending').increment_priority()
|
||||
end)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue