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

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