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`)
|
`gw` Toggle work-in-progress status (`wip`)
|
||||||
`gb` Toggle blocked status (`blocked`)
|
`gb` Toggle blocked status (`blocked`)
|
||||||
`g/` Toggle cancelled status (`cancelled`)
|
`g/` Toggle cancelled status (`cancelled`)
|
||||||
|
`ge` Open markdown detail buffer for task notes (`edit_notes`)
|
||||||
`gf` Prompt for filter predicates (`filter`)
|
`gf` Prompt for filter predicates (`filter`)
|
||||||
`<Tab>` Switch between category / queue view (`view`)
|
`<Tab>` Switch between category / queue view (`view`)
|
||||||
`gz` Undo the last `:w` save (`undo`)
|
`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
|
Decrement the priority level for the task under the cursor, clamped
|
||||||
at 0. Default key: `<C-x>`.
|
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)*
|
||||||
<Plug>(pending-open-line)
|
<Plug>(pending-open-line)
|
||||||
Insert a correctly-formatted blank task line below the cursor.
|
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
|
virtual text so tasks remain identifiable across categories. The
|
||||||
buffer is named `pending://queue`.
|
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*
|
FILTERS *pending-filters*
|
||||||
|
|
||||||
|
|
@ -789,6 +819,7 @@ loads: >lua
|
||||||
wip = 'gw',
|
wip = 'gw',
|
||||||
blocked = 'gb',
|
blocked = 'gb',
|
||||||
cancelled = 'g/',
|
cancelled = 'g/',
|
||||||
|
edit_notes = 'ge',
|
||||||
},
|
},
|
||||||
sync = {
|
sync = {
|
||||||
gcal = {},
|
gcal = {},
|
||||||
|
|
@ -1651,6 +1682,7 @@ Task fields: ~
|
||||||
{entry} (string) ISO 8601 UTC timestamp of creation.
|
{entry} (string) ISO 8601 UTC timestamp of creation.
|
||||||
{modified} (string) ISO 8601 UTC timestamp of last modification.
|
{modified} (string) ISO 8601 UTC timestamp of last modification.
|
||||||
{end} (string) ISO 8601 UTC timestamp of completion or deletion.
|
{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.
|
{order} (integer) Relative ordering within a category.
|
||||||
|
|
||||||
Any field not in the list above is preserved in `_extra` and written back on
|
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, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
|
||||||
vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', 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, 'PendingForgeClosed', { link = 'Comment', default = true })
|
||||||
|
vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return string
|
---@return string
|
||||||
|
|
@ -805,4 +806,213 @@ function M.open()
|
||||||
return task_bufnr
|
return task_bufnr
|
||||||
end
|
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
|
return M
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@
|
||||||
---@field priority_up_visual? string|false
|
---@field priority_up_visual? string|false
|
||||||
---@field priority_down_visual? string|false
|
---@field priority_down_visual? string|false
|
||||||
---@field cancelled? string|false
|
---@field cancelled? string|false
|
||||||
|
---@field edit_notes? string|false
|
||||||
|
|
||||||
---@class pending.CategoryViewConfig
|
---@class pending.CategoryViewConfig
|
||||||
---@field order? string[]
|
---@field order? string[]
|
||||||
|
|
@ -163,6 +164,7 @@ local defaults = {
|
||||||
wip = 'gw',
|
wip = 'gw',
|
||||||
blocked = 'gb',
|
blocked = 'gb',
|
||||||
cancelled = 'g/',
|
cancelled = 'g/',
|
||||||
|
edit_notes = 'ge',
|
||||||
priority_up = '<C-a>',
|
priority_up = '<C-a>',
|
||||||
priority_down = '<C-x>',
|
priority_down = '<C-x>',
|
||||||
priority_up_visual = 'g<C-a>',
|
priority_up_visual = 'g<C-a>',
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,9 @@ function M._setup_buf_mappings(bufnr)
|
||||||
open_line_above = function()
|
open_line_above = function()
|
||||||
buffer.open_line(true)
|
buffer.open_line(true)
|
||||||
end,
|
end,
|
||||||
|
edit_notes = function()
|
||||||
|
M.open_detail()
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, fn in pairs(actions) do
|
for name, fn in pairs(actions) do
|
||||||
|
|
@ -888,6 +891,46 @@ function M.toggle_status(target_status)
|
||||||
end
|
end
|
||||||
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'
|
---@param direction 'up'|'down'
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.move_task(direction)
|
function M.move_task(direction)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ local config = require('pending.config')
|
||||||
---@field entry string
|
---@field entry string
|
||||||
---@field modified string
|
---@field modified string
|
||||||
---@field end? string
|
---@field end? string
|
||||||
|
---@field notes? string
|
||||||
---@field order integer
|
---@field order integer
|
||||||
---@field _extra? pending.TaskExtra
|
---@field _extra? pending.TaskExtra
|
||||||
|
|
||||||
|
|
@ -93,6 +94,7 @@ local known_fields = {
|
||||||
entry = true,
|
entry = true,
|
||||||
modified = true,
|
modified = true,
|
||||||
['end'] = true,
|
['end'] = true,
|
||||||
|
notes = true,
|
||||||
order = true,
|
order = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,6 +126,9 @@ local function task_to_table(task)
|
||||||
if task['end'] then
|
if task['end'] then
|
||||||
t['end'] = task['end']
|
t['end'] = task['end']
|
||||||
end
|
end
|
||||||
|
if task.notes then
|
||||||
|
t.notes = task.notes
|
||||||
|
end
|
||||||
if task.order and task.order ~= 0 then
|
if task.order and task.order ~= 0 then
|
||||||
t.order = task.order
|
t.order = task.order
|
||||||
end
|
end
|
||||||
|
|
@ -150,6 +155,7 @@ local function table_to_task(t)
|
||||||
entry = t.entry,
|
entry = t.entry,
|
||||||
modified = t.modified,
|
modified = t.modified,
|
||||||
['end'] = t['end'],
|
['end'] = t['end'],
|
||||||
|
notes = t.notes,
|
||||||
order = t.order or 0,
|
order = t.order or 0,
|
||||||
_extra = {},
|
_extra = {},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,7 @@ function M.priority_view(tasks)
|
||||||
forge_ref = task._extra and task._extra._forge_ref or nil,
|
forge_ref = task._extra and task._extra._forge_ref or nil,
|
||||||
forge_cache = task._extra and task._extra._forge_cache or nil,
|
forge_cache = task._extra and task._extra._forge_cache or nil,
|
||||||
forge_spans = compute_forge_spans(task, prefix_len),
|
forge_spans = compute_forge_spans(task, prefix_len),
|
||||||
|
has_notes = task.notes ~= nil and task.notes ~= '',
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,10 @@ vim.keymap.set('n', '<Plug>(pending-cancelled)', function()
|
||||||
require('pending').toggle_status('cancelled')
|
require('pending').toggle_status('cancelled')
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
vim.keymap.set('n', '<Plug>(pending-edit-notes)', function()
|
||||||
|
require('pending').open_detail()
|
||||||
|
end)
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
|
vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
|
||||||
require('pending').increment_priority()
|
require('pending').increment_priority()
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue