diff --git a/doc/pending.txt b/doc/pending.txt index 541f773..7026922 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -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`) `` 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: ``. + *(pending-edit-notes)* +(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`. + *(pending-open-line)* (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/`. The buffer replaces the task list in +the same split. + +Layout: ~ + + Line 1: `# ` (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 diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 403205d..f65ebaa 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index c282dbd..0015b37 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -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 = '', priority_down = '', priority_up_visual = 'g', diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 38fdf50..5c28998 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -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) diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 7c43c0d..0938eda 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -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 = {}, } diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 7afeeb7..3dbd06f 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -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 diff --git a/plugin/pending.lua b/plugin/pending.lua index 8e2f633..9f25dd5 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -407,6 +407,10 @@ vim.keymap.set('n', '(pending-cancelled)', function() require('pending').toggle_status('cancelled') end) +vim.keymap.set('n', '(pending-edit-notes)', function() + require('pending').open_detail() +end) + vim.keymap.set('n', '(pending-priority-up)', function() require('pending').increment_priority() end)