feat(file-token): add file: inline metadata token with gf navigation
Problem: there was no way to link a task to a specific location in a source file, or to quickly jump from a task to the relevant code. Solution: add a file:<path>:<line> inline token that stores a relative file reference in task._extra.file. Virtual text renders basename:line in a new PendingFile highlight group. A buffer-local gf mapping (configurable via keymaps.goto_file) opens the file at the given line. M.add_here() lets users attach the current cursor position to any task via vim.ui.select(). M.edit() gains -file support to clear the reference. <Plug>(pending-goto-file) and <Plug>(pending-add-here) are exposed for custom mappings.
This commit is contained in:
parent
3da23c924a
commit
7835dc4687
9 changed files with 257 additions and 9 deletions
|
|
@ -30,7 +30,7 @@ concealed tokens and are never visible during editing.
|
|||
|
||||
Features: ~
|
||||
- Oil-style buffer editing: standard Vim motions for all task operations
|
||||
- Inline metadata syntax: `due:`, `cat:`, and `rec:` tokens parsed on `:w`
|
||||
- Inline metadata syntax: `due:`, `cat:`, `rec:`, and `file:` tokens parsed on `:w`
|
||||
- Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday
|
||||
names, month names, ordinals, and more
|
||||
- Recurring tasks with automatic next-date spawning on completion
|
||||
|
|
@ -101,6 +101,7 @@ Supported tokens: ~
|
|||
`due:<name>` Resolve a named date (see |pending-dates| below).
|
||||
`cat:Name` Move the task to the named category on save.
|
||||
`rec:<pattern>` Set a recurrence rule (see |pending-recurrence|).
|
||||
`file:<path>:<n>` Attach a file reference (see |pending-file-token|).
|
||||
|
||||
The token name for due dates defaults to `due` and is configurable via
|
||||
`date_syntax` in |pending-config|. The token name for recurrence defaults to
|
||||
|
|
@ -118,10 +119,44 @@ placed under the `Errands` category header.
|
|||
|
||||
Parsing stops at the first token that is not a recognised metadata token.
|
||||
Repeated tokens of the same type also stop parsing — only one `due:`, one
|
||||
`cat:`, and one `rec:` per task line are consumed.
|
||||
`cat:`, one `rec:`, and one `file:` per task line are consumed.
|
||||
|
||||
Omnifunc completion is available for all three token types. In insert mode,
|
||||
type `due:`, `cat:`, or `rec:` and press `<C-x><C-o>` to see suggestions.
|
||||
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
|
||||
In insert mode, type the token prefix and press `<C-x><C-o>` to see
|
||||
suggestions.
|
||||
|
||||
==============================================================================
|
||||
FILE TOKEN *pending-file-token*
|
||||
|
||||
The `file:` inline token attaches a source file reference to a task. The
|
||||
syntax is: >
|
||||
|
||||
file:<relative-path>:<line-number>
|
||||
<
|
||||
|
||||
The path is stored relative to the directory containing the data file. The
|
||||
token is rendered as virtual text at the end of the task line, showing only
|
||||
the basename and line number (e.g. `auth.lua:42`) using the |PendingFile|
|
||||
highlight group.
|
||||
|
||||
Example: >
|
||||
|
||||
Fix null pointer file:src/auth.lua:42
|
||||
Update tests file:spec/parse_spec.lua:100
|
||||
<
|
||||
|
||||
`gf` in normal mode in the task buffer follows the file reference, opening
|
||||
the file and jumping to the specified line. The default key is `gf` and can
|
||||
be changed via the `goto_file` keymap in |pending-config|. Set it to `false`
|
||||
to disable.
|
||||
|
||||
To attach the current file and cursor position to an existing task, invoke
|
||||
|<Plug>(pending-add-here)| from any source file. A `vim.ui.select()` picker
|
||||
lists all active tasks; selecting one records the current file and line.
|
||||
|
||||
To clear a file reference with `:Pending edit`: >vim
|
||||
:Pending edit 5 -file
|
||||
<
|
||||
|
||||
==============================================================================
|
||||
DATE INPUT *pending-dates*
|
||||
|
|
@ -268,6 +303,29 @@ COMMANDS *pending-commands*
|
|||
|
||||
`gcal` Google Calendar one-way push. See |pending-gcal|.
|
||||
|
||||
*:Pending-edit*
|
||||
:Pending edit {id} [{operations}]
|
||||
Edit metadata on an existing task without opening the buffer. {id} is the
|
||||
numeric task ID. One or more operations follow: >vim
|
||||
:Pending edit 5 due:tomorrow cat:Work +!
|
||||
:Pending edit 5 -due -cat -rec
|
||||
:Pending edit 5 rec:!weekly due:fri
|
||||
:Pending edit 5 -file
|
||||
<
|
||||
Operations: ~
|
||||
`due:<date>` Set due date (accepts all |pending-dates| vocabulary).
|
||||
`cat:<name>` Set category.
|
||||
`rec:<pattern>` Set recurrence (prefix `!` for completion-based).
|
||||
`+!` Add priority flag.
|
||||
`-!` Remove priority flag.
|
||||
`-due` Clear due date.
|
||||
`-cat` Clear category.
|
||||
`-rec` Clear recurrence.
|
||||
`-file` Clear the attached file reference (see |pending-file-token|).
|
||||
|
||||
Tab completion is available for IDs, field names, date values, categories,
|
||||
and recurrence patterns.
|
||||
|
||||
*:Pending-undo*
|
||||
:Pending undo
|
||||
Undo the last `:w` save, restoring the task store to its previous state.
|
||||
|
|
@ -295,6 +353,7 @@ Default buffer-local keys: ~
|
|||
`U` Undo the last `:w` save (`undo`)
|
||||
`o` Insert a new task line below (`open_line`)
|
||||
`O` Insert a new task line above (`open_line_above`)
|
||||
`gf` Open the file attached to the task under the cursor (`goto_file`)
|
||||
`zc` Fold the current category section (category view only)
|
||||
`zo` Unfold the current category section (category view only)
|
||||
|
||||
|
|
@ -396,6 +455,21 @@ All motions support count: `3]]` jumps three headers forward. `]]` and
|
|||
<Plug>(pending-prev-task)
|
||||
Jump to the previous task line, skipping headers and blanks.
|
||||
|
||||
*<Plug>(pending-goto-file)*
|
||||
<Plug>(pending-goto-file)
|
||||
Open the file attached to the task under the cursor. If the cursor is not
|
||||
on a task line, or the task has no file reference, a warning is shown. If
|
||||
the referenced file cannot be read, an error is shown.
|
||||
See |pending-file-token|.
|
||||
|
||||
*<Plug>(pending-add-here)*
|
||||
<Plug>(pending-add-here)
|
||||
Attach the current file and cursor line to an existing task. Invoke from
|
||||
any source file (not the pending buffer itself) to open a picker listing
|
||||
all active tasks. The selected task receives a `file:` reference pointing
|
||||
to the current buffer's file and the cursor's line number.
|
||||
See |pending-file-token|.
|
||||
|
||||
Example configuration: >lua
|
||||
vim.keymap.set('n', '<leader>t', '<Plug>(pending-open)')
|
||||
vim.keymap.set('n', '<leader>T', '<Plug>(pending-toggle)')
|
||||
|
|
@ -452,6 +526,7 @@ loads: >lua
|
|||
prev_header = '[[',
|
||||
next_task = ']t',
|
||||
prev_task = '[t',
|
||||
goto_file = 'gf',
|
||||
},
|
||||
sync = {
|
||||
gcal = {
|
||||
|
|
@ -512,6 +587,11 @@ Fields: ~
|
|||
See |pending-mappings| for the full list of actions
|
||||
and their default keys.
|
||||
|
||||
{goto_file} (string|false, default: 'gf')
|
||||
Open the file attached to the task under the
|
||||
cursor. Set to `false` to disable. See
|
||||
|pending-file-token|.
|
||||
|
||||
{debug} (boolean, default: false)
|
||||
Enable diagnostic logging. When `true`, textobj
|
||||
motions, mapping registration, and cursor jumps
|
||||
|
|
@ -760,6 +840,12 @@ PendingRecur Applied to the recurrence indicator virtual text shown
|
|||
alongside due dates for recurring tasks.
|
||||
Default: links to `DiagnosticInfo`.
|
||||
|
||||
*PendingFile*
|
||||
PendingFile Applied to the file reference virtual text shown for tasks
|
||||
that have a `file:` token attached (see |pending-file-token|).
|
||||
Displays the basename and line number (e.g. `auth.lua:42`).
|
||||
Default: links to `Directory`.
|
||||
|
||||
To override a group in your colorscheme or config: >lua
|
||||
vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true })
|
||||
<
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ local function apply_extmarks(bufnr, line_meta)
|
|||
if m.due then
|
||||
table.insert(virt_parts, { m.due, due_hl })
|
||||
end
|
||||
if m.file then
|
||||
local display = m.file:match('([^/]+:%d+)$') or m.file
|
||||
table.insert(virt_parts, { display, 'PendingFile' })
|
||||
end
|
||||
if #virt_parts > 0 then
|
||||
for p = 1, #virt_parts - 1 do
|
||||
virt_parts[p][1] = virt_parts[p][1] .. ' '
|
||||
|
|
@ -170,6 +174,7 @@ local function setup_highlights()
|
|||
vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true })
|
||||
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
|
||||
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
|
||||
vim.api.nvim_set_hl(0, 'PendingFile', { link = 'Directory', default = true })
|
||||
end
|
||||
|
||||
local function snapshot_folds(bufnr)
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ function M.omnifunc(findstart, base)
|
|||
{ vim.pesc(dk) .. ':([%S]*)$', dk },
|
||||
{ 'cat:([%S]*)$', 'cat' },
|
||||
{ vim.pesc(rk) .. ':([%S]*)$', rk },
|
||||
{ 'file:([%S]*)$', 'file' },
|
||||
}
|
||||
|
||||
for _, check in ipairs(checks) do
|
||||
|
|
@ -162,6 +163,7 @@ function M.omnifunc(findstart, base)
|
|||
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
|
||||
end
|
||||
end
|
||||
elseif source == 'file' then
|
||||
end
|
||||
|
||||
return matches
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
---@field prev_header? string|false
|
||||
---@field next_task? string|false
|
||||
---@field prev_task? string|false
|
||||
---@field goto_file? string|false
|
||||
|
||||
---@class pending.Config
|
||||
---@field data_path string
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ local store = require('pending.store')
|
|||
---@field due? string
|
||||
---@field rec? string
|
||||
---@field rec_mode? string
|
||||
---@field file? string
|
||||
---@field lnum integer
|
||||
|
||||
---@class pending.diff
|
||||
|
|
@ -52,6 +53,7 @@ function M.parse_buffer(lines)
|
|||
due = metadata.due,
|
||||
rec = metadata.rec,
|
||||
rec_mode = metadata.rec_mode,
|
||||
file = metadata.file,
|
||||
lnum = i,
|
||||
})
|
||||
end
|
||||
|
|
@ -65,8 +67,9 @@ function M.parse_buffer(lines)
|
|||
end
|
||||
|
||||
---@param lines string[]
|
||||
---@param hidden_ids? table<integer, boolean>
|
||||
---@return nil
|
||||
function M.apply(lines)
|
||||
function M.apply(lines, hidden_ids)
|
||||
local parsed = M.parse_buffer(lines)
|
||||
local now = timestamp()
|
||||
local data = store.data()
|
||||
|
|
@ -127,6 +130,19 @@ function M.apply(lines)
|
|||
task.recur_mode = entry.rec_mode
|
||||
changed = true
|
||||
end
|
||||
local old_file = (task._extra and task._extra.file) or nil
|
||||
if entry.file ~= old_file then
|
||||
task._extra = task._extra or {}
|
||||
if entry.file then
|
||||
task._extra.file = entry.file
|
||||
else
|
||||
task._extra.file = nil
|
||||
if next(task._extra) == nil then
|
||||
task._extra = nil
|
||||
end
|
||||
end
|
||||
changed = true
|
||||
end
|
||||
if entry.status and task.status ~= entry.status then
|
||||
task.status = entry.status
|
||||
if entry.status == 'done' then
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
local buffer = require('pending.buffer')
|
||||
local config = require('pending.config')
|
||||
local diff = require('pending.diff')
|
||||
local parse = require('pending.parse')
|
||||
local store = require('pending.store')
|
||||
|
|
@ -240,6 +241,16 @@ function M._setup_buf_mappings(bufnr)
|
|||
end, opts)
|
||||
end
|
||||
end
|
||||
|
||||
local goto_key = km.goto_file
|
||||
if goto_key == nil then
|
||||
goto_key = 'gf'
|
||||
end
|
||||
if goto_key and goto_key ~= false then
|
||||
vim.keymap.set('n', goto_key --[[@as string]], function()
|
||||
M.goto_file()
|
||||
end, opts)
|
||||
end
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
|
|
@ -550,6 +561,9 @@ local function parse_edit_token(token)
|
|||
if token == '-rec' or token == '-' .. rk then
|
||||
return 'recur', vim.NIL, nil
|
||||
end
|
||||
if token == '-file' then
|
||||
return 'file_clear', true, nil
|
||||
end
|
||||
|
||||
local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$')
|
||||
if due_val then
|
||||
|
|
@ -594,10 +608,11 @@ local function parse_edit_token(token)
|
|||
.. dk
|
||||
.. ':<date>, cat:<name>, '
|
||||
.. rk
|
||||
.. ':<pattern>, +!, -!, -'
|
||||
.. ':<pattern>, file:<path>:<line>, +!, -!, -'
|
||||
.. dk
|
||||
.. ', -cat, -'
|
||||
.. rk
|
||||
.. ', -file'
|
||||
end
|
||||
|
||||
---@param id_str string
|
||||
|
|
@ -676,6 +691,9 @@ function M.edit(id_str, rest)
|
|||
elseif field == 'priority' then
|
||||
updates.priority = value
|
||||
table.insert(feedback, value == 1 and 'priority added' or 'priority removed')
|
||||
elseif field == 'file_clear' then
|
||||
updates.file_clear = true
|
||||
table.insert(feedback, 'file reference removed')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -687,6 +705,18 @@ function M.edit(id_str, rest)
|
|||
end
|
||||
|
||||
store.update(id, updates)
|
||||
|
||||
if updates.file_clear then
|
||||
local t = store.get(id)
|
||||
if t and t._extra then
|
||||
t._extra.file = nil
|
||||
if next(t._extra) == nil then
|
||||
t._extra = nil
|
||||
end
|
||||
t.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
end
|
||||
end
|
||||
|
||||
store.save()
|
||||
|
||||
local bufnr = buffer.bufnr()
|
||||
|
|
@ -697,6 +727,90 @@ function M.edit(id_str, rest)
|
|||
vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.goto_file()
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
if vim.bo[bufnr].filetype ~= 'pending' then
|
||||
return
|
||||
end
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
local meta = buffer.meta()
|
||||
local m = meta and meta[lnum]
|
||||
if not m or m.type ~= 'task' then
|
||||
vim.notify('No task on this line', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local task = store.get(m.id)
|
||||
if not task or not task._extra or not task._extra.file then
|
||||
vim.notify('No file attached to this task', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local file_spec = task._extra.file
|
||||
local rel_path, line_str = file_spec:match('^(.+):(%d+)$')
|
||||
if not rel_path then
|
||||
vim.notify('Invalid file spec: ' .. file_spec, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
|
||||
local abs_path = data_dir .. '/' .. rel_path
|
||||
if vim.fn.filereadable(abs_path) == 0 then
|
||||
vim.notify('File not found: ' .. abs_path, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
vim.cmd.edit(abs_path)
|
||||
local lnum_target = tonumber(line_str) or 1
|
||||
vim.api.nvim_win_set_cursor(0, { lnum_target, 0 })
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.add_here()
|
||||
local cur_bufnr = vim.api.nvim_get_current_buf()
|
||||
if vim.bo[cur_bufnr].filetype == 'pending' then
|
||||
vim.notify('Already in pending buffer', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local cur_file = vim.api.nvim_buf_get_name(cur_bufnr)
|
||||
if cur_file == '' or vim.fn.filereadable(cur_file) == 0 then
|
||||
vim.notify('Not editing a readable file', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local cur_lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
|
||||
local abs_file = vim.fn.fnamemodify(cur_file, ':p')
|
||||
local rel_file
|
||||
if abs_file:sub(1, #data_dir + 1) == data_dir .. '/' then
|
||||
rel_file = abs_file:sub(#data_dir + 2)
|
||||
else
|
||||
rel_file = abs_file
|
||||
end
|
||||
local file_spec = rel_file .. ':' .. cur_lnum
|
||||
store.load()
|
||||
local tasks = store.active_tasks()
|
||||
if #tasks == 0 then
|
||||
vim.notify('No active tasks', vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, task in ipairs(tasks) do
|
||||
table.insert(items, task)
|
||||
end
|
||||
vim.ui.select(items, {
|
||||
prompt = 'Attach file to task:',
|
||||
format_item = function(task)
|
||||
return '[' .. task.id .. '] ' .. task.description
|
||||
end,
|
||||
}, function(task)
|
||||
if not task then
|
||||
return
|
||||
end
|
||||
task._extra = task._extra or {}
|
||||
task._extra.file = file_spec
|
||||
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
||||
store.save()
|
||||
vim.notify('Attached ' .. file_spec .. ' to task ' .. task.id)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param args string
|
||||
---@return nil
|
||||
function M.command(args)
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ end
|
|||
|
||||
---@param text string
|
||||
---@return string description
|
||||
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
|
||||
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion', file?: string } metadata
|
||||
function M.body(text)
|
||||
local tokens = {}
|
||||
for token in text:gmatch('%S+') do
|
||||
|
|
@ -481,7 +481,18 @@ function M.body(text)
|
|||
metadata.rec = raw_spec
|
||||
i = i - 1
|
||||
else
|
||||
break
|
||||
local file_path_val, file_line_val = token:match('^file:(.+):(%d+)$')
|
||||
if file_path_val and file_line_val then
|
||||
if metadata.file then
|
||||
break
|
||||
end
|
||||
metadata.file = file_path_val .. ':' .. file_line_val
|
||||
i = i - 1
|
||||
elseif token:match('^file:') then
|
||||
break
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -499,7 +510,7 @@ end
|
|||
|
||||
---@param text string
|
||||
---@return string description
|
||||
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
|
||||
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion', file?: string } metadata
|
||||
function M.command_add(text)
|
||||
local cat_prefix = text:match('^(%S.-):%s')
|
||||
if cat_prefix then
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ local parse = require('pending.parse')
|
|||
---@field show_category? boolean
|
||||
---@field priority? integer
|
||||
---@field recur? string
|
||||
---@field file? string
|
||||
|
||||
---@class pending.views
|
||||
local M = {}
|
||||
|
|
@ -159,6 +160,7 @@ function M.category_view(tasks)
|
|||
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due)
|
||||
or nil,
|
||||
recur = task.recur,
|
||||
file = task._extra and task._extra.file or nil,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
@ -210,6 +212,7 @@ function M.priority_view(tasks)
|
|||
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil,
|
||||
show_category = true,
|
||||
recur = task.recur,
|
||||
file = task._extra and task._extra.file or nil,
|
||||
})
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ local function edit_field_candidates()
|
|||
dk .. ':',
|
||||
'cat:',
|
||||
rk .. ':',
|
||||
'file:',
|
||||
'+!',
|
||||
'-!',
|
||||
'-' .. dk,
|
||||
'-cat',
|
||||
'-' .. rk,
|
||||
'-file',
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -270,3 +272,11 @@ end)
|
|||
vim.keymap.set({ 'n', 'x', 'o' }, '<Plug>(pending-prev-task)', function()
|
||||
require('pending.textobj').prev_task(vim.v.count1)
|
||||
end)
|
||||
|
||||
vim.keymap.set('n', '<Plug>(pending-goto-file)', function()
|
||||
require('pending').goto_file()
|
||||
end)
|
||||
|
||||
vim.keymap.set('n', '<Plug>(pending-add-here)', function()
|
||||
require('pending').add_here()
|
||||
end)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue