From a2e0e296acacec93418e7d9c370cf4a838a25eac Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:12:48 -0500 Subject: [PATCH] feat(file-token): file: inline metadata token with gf navigation (#45) * 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:: 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. (pending-goto-file) and (pending-add-here) are exposed for custom mappings. * test(file-token): add parse, diff, views, edit, and navigation tests Problem: the file: token implementation had no test coverage. Solution: add spec/file_spec.lua covering parse.body extraction, malformed token handling, duplicate token stop-parsing, diff reconciliation (store/update/clear/round-trip), LineMeta population in both views, :Pending edit -file, and goto_file notify paths for no-file and unreadable-file cases. All 292 tests pass. * style: apply stylua formatting * fix(types): remove empty elseif block, fix file? annotation nullability --- doc/pending.txt | 94 ++++++++++- lua/pending/buffer.lua | 5 + lua/pending/complete.lua | 1 + lua/pending/config.lua | 1 + lua/pending/diff.lua | 15 ++ lua/pending/init.lua | 116 ++++++++++++- lua/pending/parse.lua | 17 +- lua/pending/views.lua | 3 + plugin/pending.lua | 10 ++ spec/file_spec.lua | 351 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 spec/file_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index 9122a2e..08c63f9 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -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:` Resolve a named date (see |pending-dates| below). `cat:Name` Move the task to the named category on save. `rec:` Set a recurrence rule (see |pending-recurrence|). + `file::` 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 `` to see suggestions. +Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types. +In insert mode, type the token prefix and press `` to see +suggestions. + +============================================================================== +FILE TOKEN *pending-file-token* + +The `file:` inline token attaches a source file reference to a task. The +syntax is: > + + file:: +< + +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 +|(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* @@ -292,6 +327,29 @@ COMMANDS *pending-commands* See |pending-filters| for the full list of supported predicates. + *: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:` Set due date (accepts all |pending-dates| vocabulary). + `cat:` Set category. + `rec:` 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. @@ -319,6 +377,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) @@ -420,6 +479,21 @@ All motions support count: `3]]` jumps three headers forward. `]]` and (pending-prev-task) Jump to the previous task line, skipping headers and blanks. + *(pending-goto-file)* +(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|. + + *(pending-add-here)* +(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', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -517,6 +591,7 @@ loads: >lua prev_header = '[[', next_task = ']t', prev_task = '[t', + goto_file = 'gf', }, sync = { gcal = { @@ -577,6 +652,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 @@ -901,6 +981,12 @@ PendingFilter Applied to the `FILTER:` header line shown at the top of the buffer when a filter is active. Default: links to `DiagnosticWarn`. + *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 }) < diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 0372ef6..0aa78bb 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -164,6 +164,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] .. ' ' @@ -199,6 +203,7 @@ local function setup_highlights() 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, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) + vim.api.nvim_set_hl(0, 'PendingFile', { link = 'Directory', default = true }) end local function snapshot_folds(bufnr) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 79f338b..6c2b964 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index a1767db..000ac2b 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -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 diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 4fd83c3..c731d95 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -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 @@ -57,6 +58,7 @@ function M.parse_buffer(lines) due = metadata.due, rec = metadata.rec, rec_mode = metadata.rec_mode, + file = metadata.file, lnum = i, }) end @@ -133,6 +135,19 @@ function M.apply(lines, hidden_ids) 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 diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 7409fb5..73b3051 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -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') @@ -305,6 +306,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 @@ -629,6 +640,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 @@ -673,10 +687,11 @@ local function parse_edit_token(token) .. dk .. ':, cat:, ' .. rk - .. ':, +!, -!, -' + .. ':, file::, +!, -!, -' .. dk .. ', -cat, -' .. rk + .. ', -file' end ---@param id_str string @@ -755,6 +770,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 @@ -766,6 +784,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() @@ -776,6 +806,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) diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 9ce4c0d..6d43be4 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -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 diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 286db9a..5447a90 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -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 diff --git a/plugin/pending.lua b/plugin/pending.lua index be546c5..5cd94d0 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -12,11 +12,13 @@ local function edit_field_candidates() dk .. ':', 'cat:', rk .. ':', + 'file:', '+!', '-!', '-' .. dk, '-cat', '-' .. rk, + '-file', } end @@ -294,3 +296,11 @@ end) vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-task)', function() require('pending.textobj').prev_task(vim.v.count1) end) + +vim.keymap.set('n', '(pending-goto-file)', function() + require('pending').goto_file() +end) + +vim.keymap.set('n', '(pending-add-here)', function() + require('pending').add_here() +end) diff --git a/spec/file_spec.lua b/spec/file_spec.lua new file mode 100644 index 0000000..9835387 --- /dev/null +++ b/spec/file_spec.lua @@ -0,0 +1,351 @@ +require('spec.helpers') + +local config = require('pending.config') +local diff = require('pending.diff') +local parse = require('pending.parse') +local store = require('pending.store') +local views = require('pending.views') + +describe('file token', function() + local tmpdir + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + store.load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + store.unload() + end) + + describe('parse.body', function() + it('extracts file token with path and line number', function() + local desc, meta = parse.body('Fix the bug file:src/auth.lua:42') + assert.are.equal('Fix the bug', desc) + assert.are.equal('src/auth.lua:42', meta.file) + end) + + it('extracts file token with nested path', function() + local desc, meta = parse.body('Do something file:lua/pending/init.lua:100') + assert.are.equal('Do something', desc) + assert.are.equal('lua/pending/init.lua:100', meta.file) + end) + + it('strips file token from description', function() + local desc, meta = parse.body('Task description file:foo.lua:1') + assert.are.equal('Task description', desc) + assert.are.equal('foo.lua:1', meta.file) + end) + + it('stops parsing on duplicate file token', function() + local desc, meta = parse.body('Task file:b.lua:2 file:a.lua:1') + assert.are.equal('Task file:b.lua:2', desc) + assert.are.equal('a.lua:1', meta.file) + end) + + it('treats malformed file token (no line number) as non-metadata', function() + local desc, meta = parse.body('Task file:nolineno') + assert.are.equal('Task file:nolineno', desc) + assert.is_nil(meta.file) + end) + + it('treats file: prefix with no path as non-metadata', function() + local desc, meta = parse.body('Task file:') + assert.are.equal('Task file:', desc) + assert.is_nil(meta.file) + end) + + it('handles file token alongside other metadata tokens', function() + local desc, meta = parse.body('Task cat:Work file:src/main.lua:10') + assert.are.equal('Task', desc) + assert.are.equal('Work', meta.cat) + assert.are.equal('src/main.lua:10', meta.file) + end) + + it('does not extract file token when line number is not numeric', function() + local desc, meta = parse.body('Task file:src/foo.lua:abc') + assert.are.equal('Task file:src/foo.lua:abc', desc) + assert.is_nil(meta.file) + end) + end) + + describe('diff reconciliation', function() + it('stores file field in _extra on write', function() + local t = store.add({ description = 'Task one' }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', + } + diff.apply(lines) + local updated = store.get(t.id) + assert.is_not_nil(updated._extra) + assert.are.equal('src/auth.lua:42', updated._extra.file) + end) + + it('updates file field when token changes', function() + local t = store.add({ description = 'Task one', _extra = { file = 'old.lua:1' } }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one file:new.lua:99', + } + diff.apply(lines) + local updated = store.get(t.id) + assert.are.equal('new.lua:99', updated._extra.file) + end) + + it('clears file field when token is removed from line', function() + local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one', + } + diff.apply(lines) + local updated = store.get(t.id) + assert.is_nil(updated._extra) + end) + + it('preserves other _extra fields when file is cleared', function() + local t = store.add({ + description = 'Task one', + _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' }, + }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one', + } + diff.apply(lines) + local updated = store.get(t.id) + assert.is_not_nil(updated._extra) + assert.is_nil(updated._extra.file) + assert.are.equal('abc123', updated._extra._gcal_event_id) + end) + + it('round-trips file field through JSON', function() + local t = store.add({ description = 'Task one' }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', + } + diff.apply(lines) + store.unload() + store.load() + local loaded = store.get(t.id) + assert.is_not_nil(loaded._extra) + assert.are.equal('src/auth.lua:42', loaded._extra.file) + end) + + it('accepts optional hidden_ids parameter without error', function() + local t = store.add({ description = 'Task one' }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one', + } + assert.has_no_error(function() + diff.apply(lines, {}) + end) + end) + end) + + describe('LineMeta', function() + it('category_view populates file field in LineMeta', function() + local t = store.add({ + description = 'Task one', + _extra = { file = 'src/auth.lua:42' }, + }) + store.save() + local tasks = store.active_tasks() + local _, meta = views.category_view(tasks) + local task_meta = nil + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + break + end + end + assert.is_not_nil(task_meta) + assert.are.equal('src/auth.lua:42', task_meta.file) + end) + + it('priority_view populates file field in LineMeta', function() + local t = store.add({ + description = 'Task one', + _extra = { file = 'src/auth.lua:42' }, + }) + store.save() + local tasks = store.active_tasks() + local _, meta = views.priority_view(tasks) + local task_meta = nil + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + break + end + end + assert.is_not_nil(task_meta) + assert.are.equal('src/auth.lua:42', task_meta.file) + end) + + it('file field is nil in LineMeta when task has no file', function() + local t = store.add({ description = 'Task one' }) + store.save() + local tasks = store.active_tasks() + local _, meta = views.category_view(tasks) + local task_meta = nil + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + break + end + end + assert.is_not_nil(task_meta) + assert.is_nil(task_meta.file) + end) + end) + + describe(':Pending edit -file', function() + it('clears file reference from task', function() + local pending = require('pending') + local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + store.save() + pending.edit(tostring(t.id), '-file') + local updated = store.get(t.id) + assert.is_nil(updated._extra) + end) + + it('shows feedback when file reference is removed', function() + local pending = require('pending') + local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + store.save() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit(tostring(t.id), '-file') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('file reference removed')) + end) + + it('does not error when task has no file', function() + local pending = require('pending') + local t = store.add({ description = 'Task one' }) + store.save() + assert.has_no_error(function() + pending.edit(tostring(t.id), '-file') + end) + end) + + it('preserves other _extra fields when -file is used', function() + local pending = require('pending') + local t = store.add({ + description = 'Task one', + _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' }, + }) + store.save() + pending.edit(tostring(t.id), '-file') + local updated = store.get(t.id) + assert.is_not_nil(updated._extra) + assert.is_nil(updated._extra.file) + assert.are.equal('abc', updated._extra._gcal_event_id) + end) + end) + + describe('goto_file', function() + it('notifies warn when task has no file attached', function() + local pending = require('pending') + local buffer = require('pending.buffer') + + local t = store.add({ description = 'Task one' }) + store.save() + + local bufnr = buffer.open() + vim.bo[bufnr].filetype = 'pending' + vim.api.nvim_set_current_buf(bufnr) + + local meta = buffer.meta() + local task_lnum = nil + for lnum, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_lnum = lnum + break + end + end + assert.is_not_nil(task_lnum) + vim.api.nvim_win_set_cursor(0, { task_lnum, 0 }) + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + + pending.goto_file() + + vim.notify = orig_notify + + local warned = false + for _, m in ipairs(messages) do + if m.level == vim.log.levels.WARN and m.msg:find('No file attached') then + warned = true + end + end + assert.is_true(warned) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('notifies error when file spec is unreadable', function() + local pending = require('pending') + local buffer = require('pending.buffer') + + local t = store.add({ + description = 'Task one', + _extra = { file = 'nonexistent/path.lua:1' }, + }) + store.save() + + local bufnr = buffer.open() + vim.bo[bufnr].filetype = 'pending' + vim.api.nvim_set_current_buf(bufnr) + + local meta = buffer.meta() + local task_lnum = nil + for lnum, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_lnum = lnum + break + end + end + assert.is_not_nil(task_lnum) + vim.api.nvim_win_set_cursor(0, { task_lnum, 0 }) + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + + pending.goto_file() + + vim.notify = orig_notify + + local errored = false + for _, m in ipairs(messages) do + if m.level == vim.log.levels.ERROR and m.msg:find('File not found') then + errored = true + end + end + assert.is_true(errored) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) +end)