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:
Barrett Ruth 2026-02-26 18:20:32 -05:00
parent 3da23c924a
commit 7835dc4687
9 changed files with 257 additions and 9 deletions

View file

@ -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 })
<

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)