From 040a0bfe029a046083805e5537d83a56a778159a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 18:21:54 -0500 Subject: [PATCH] feat(filter): oil-like editable filter line with predicate dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: no way to narrow the pending buffer to a subset of tasks without manual scrolling; filtered-out tasks would be silently deleted on :w because diff.apply() marks unseen IDs as deleted. Solution: add a FILTER: line rendered at the top of the buffer when a filter is active. The line is editable — :w re-parses it and updates the hidden set. diff.apply() gains a hidden_ids param that prevents filtered-out tasks from being marked deleted. Predicates: cat:X, overdue, today, priority (space-separated AND). :Pending filter sets it programmatically; :Pending filter clear removes it. --- doc/pending.txt | 70 +++++++++++ lua/pending/buffer.lua | 44 ++++++- lua/pending/diff.lua | 12 +- lua/pending/init.lua | 83 ++++++++++++- lua/pending/views.lua | 2 +- plugin/pending.lua | 26 +++- spec/filter_spec.lua | 261 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 spec/filter_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index be369b5..a1f8198 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -268,6 +268,30 @@ COMMANDS *pending-commands* `gcal` Google Calendar one-way push. See |pending-gcal|. + *:Pending-filter* +:Pending filter {predicates} + Apply a filter to the task buffer. {predicates} is a space-separated list + of one or more predicate tokens. Only tasks matching all predicates (AND + semantics) are shown. Hidden tasks are not deleted — they are preserved in + the store and reappear when the filter is cleared. >vim + :Pending filter cat:Work + :Pending filter overdue + :Pending filter cat:Work overdue + :Pending filter priority + :Pending filter clear +< + When a filter is active the buffer's first line shows: > + FILTER: cat:Work overdue +< + The user can edit this line inline and `:w` to change the active filter. + Deleting the `FILTER:` line entirely and saving clears the filter. + `:Pending filter clear` also clears the filter programmatically. + + Tab completion after `:Pending filter ` lists available predicates and + category values. Already-used predicates are excluded from completions. + + See |pending-filters| for the full list of supported predicates. + *:Pending-undo* :Pending undo Undo the last `:w` save, restoring the task store to its previous state. @@ -421,6 +445,47 @@ Queue view: ~ *pending-view-queue* text alongside the due date virtual text so tasks remain identifiable across categories. The buffer is named `pending://queue`. +============================================================================== +FILTERS *pending-filters* + +Filters narrow the task buffer to a subset of tasks without deleting any data. +Hidden tasks are preserved in the store and reappear when the filter is +cleared. Filter state is session-local — it does not persist across Neovim +restarts. + +Set a filter with |:Pending-filter| or by editing the `FILTER:` line: >vim + :Pending filter cat:Work overdue +< + +Multiple predicates are separated by spaces and combined with AND logic — a +task must match every predicate to be shown. + +Available predicates: ~ + + `cat:X` Show only tasks whose category is exactly `X`. Tasks with no + category (assigned to `default_category`) are hidden unless + `default_category` matches `X`. + + `overdue` Show only pending tasks with a due date strictly before today. + + `today` Show only pending tasks with a due date equal to today. + + `priority` Show only tasks with priority > 0 (the `!` marker). + + `clear` Special value for |:Pending-filter| — clears the active filter + and shows all tasks. + +FILTER: line: ~ *pending-filter-line* + +When a filter is active, the first line of the task buffer is: > + FILTER: cat:Work overdue +< + +This line is editable. Write the buffer with `:w` to apply the updated +predicates. Deleting the `FILTER:` line and saving clears the filter. The +line is highlighted with |PendingFilter| and does not appear in the stored +task data. + ============================================================================== CONFIGURATION *pending-config* @@ -760,6 +825,11 @@ PendingRecur Applied to the recurrence indicator virtual text shown alongside due dates for recurring tasks. Default: links to `DiagnosticInfo`. + *PendingFilter* +PendingFilter Applied to the `FILTER:` header line shown at the top of + the buffer when a filter is active. + Default: links to `DiagnosticWarn`. + 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 a427b68..0372ef6 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -16,6 +16,10 @@ local current_view = nil local _meta = {} ---@type table> local _fold_state = {} +---@type string[] +local _filter_predicates = {} +---@type table +local _hidden_ids = {} ---@return pending.LineMeta[] function M.meta() @@ -37,6 +41,24 @@ function M.current_view_name() return current_view end +---@return string[] +function M.filter_predicates() + return _filter_predicates +end + +---@return table +function M.hidden_ids() + return _hidden_ids +end + +---@param predicates string[] +---@param hidden table +---@return nil +function M.set_filter(predicates, hidden) + _filter_predicates = predicates + _hidden_ids = hidden +end + ---@return nil function M.clear_winid() task_winid = nil @@ -124,7 +146,13 @@ local function apply_extmarks(bufnr, line_meta) vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1) for i, m in ipairs(line_meta) do local row = i - 1 - if m.type == 'task' then + if m.type == 'filter' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + end_col = #line, + hl_group = 'PendingFilter', + }) + elseif m.type == 'task' then local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' local virt_parts = {} if m.show_category and m.category then @@ -170,6 +198,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, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) end local function snapshot_folds(bufnr) @@ -225,7 +254,13 @@ function M.render(bufnr) current_view = current_view or config.get().default_view local view_label = current_view == 'priority' and 'queue' or current_view vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label) - local tasks = store.active_tasks() + local all_tasks = store.active_tasks() + local tasks = {} + for _, task in ipairs(all_tasks) do + if not _hidden_ids[task.id] then + table.insert(tasks, task) + end + end local lines, line_meta if current_view == 'priority' then @@ -234,6 +269,11 @@ function M.render(bufnr) lines, line_meta = views.category_view(tasks) end + if #_filter_predicates > 0 then + table.insert(lines, 1, 'FILTER: ' .. table.concat(_filter_predicates, ' ')) + table.insert(line_meta, 1, { type = 'filter' }) + end + _meta = line_meta snapshot_folds(bufnr) diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index daab788..4fd83c3 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -27,8 +27,13 @@ end function M.parse_buffer(lines) local result = {} local current_category = nil + local start = 1 + if lines[1] and lines[1]:match('^FILTER:') then + start = 2 + end - for i, line in ipairs(lines) do + for i = start, #lines do + local line = lines[i] local id, body = line:match('^/(%d+)/(- %[.%] .*)$') if not id then body = line:match('^(- %[.%] .*)$') @@ -65,8 +70,9 @@ function M.parse_buffer(lines) end ---@param lines string[] +---@param hidden_ids? table ---@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() @@ -160,7 +166,7 @@ function M.apply(lines) end for id, task in pairs(old_by_id) do - if not seen_ids[id] then + if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then task.status = 'deleted' task['end'] = now task.modified = now diff --git a/lua/pending/init.lua b/lua/pending/init.lua index cae13a9..7409fb5 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -94,6 +94,47 @@ function M.has_due() return c.overdue > 0 or c.today > 0 end +---@param tasks pending.Task[] +---@param predicates string[] +---@return table +local function compute_hidden_ids(tasks, predicates) + if #predicates == 0 then + return {} + end + local hidden = {} + for _, task in ipairs(tasks) do + local visible = true + for _, pred in ipairs(predicates) do + local cat_val = pred:match('^cat:(.+)$') + if cat_val then + if task.category ~= cat_val then + visible = false + break + end + elseif pred == 'overdue' then + if not (task.status == 'pending' and task.due and parse.is_overdue(task.due)) then + visible = false + break + end + elseif pred == 'today' then + if not (task.status == 'pending' and task.due and parse.is_today(task.due)) then + visible = false + break + end + elseif pred == 'priority' then + if not (task.priority and task.priority > 0) then + visible = false + break + end + end + end + if not visible then + hidden[task.id] = true + end + end + return hidden +end + ---@return integer bufnr function M.open() local bufnr = buffer.open() @@ -102,6 +143,30 @@ function M.open() return bufnr end +---@param pred_str string +---@return nil +function M.filter(pred_str) + if pred_str == 'clear' or pred_str == '' then + buffer.set_filter({}, {}) + local bufnr = buffer.bufnr() + if bufnr then + buffer.render(bufnr) + end + return + end + local predicates = {} + for word in pred_str:gmatch('%S+') do + table.insert(predicates, word) + end + local tasks = store.active_tasks() + local hidden = compute_hidden_ids(tasks, predicates) + buffer.set_filter(predicates, hidden) + local bufnr = buffer.bufnr() + if bufnr then + buffer.render(bufnr) + end +end + ---@param bufnr integer ---@return nil function M._setup_autocmds(bufnr) @@ -246,13 +311,27 @@ end ---@return nil function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local predicates = buffer.filter_predicates() + if lines[1] and lines[1]:match('^FILTER:') then + local pred_str = lines[1]:match('^FILTER:%s*(.*)$') or '' + predicates = {} + for word in pred_str:gmatch('%S+') do + table.insert(predicates, word) + end + lines = vim.list_slice(lines, 2) + elseif #buffer.filter_predicates() > 0 then + predicates = {} + end + local tasks = store.active_tasks() + local hidden = compute_hidden_ids(tasks, predicates) + buffer.set_filter(predicates, hidden) local snapshot = store.snapshot() local stack = store.undo_stack() table.insert(stack, snapshot) if #stack > UNDO_MAX then table.remove(stack, 1) end - diff.apply(lines) + diff.apply(lines, hidden) M._recompute_counts() buffer.render(bufnr) end @@ -718,6 +797,8 @@ function M.command(args) M.archive(d) elseif cmd == 'due' then M.due() + elseif cmd == 'filter' then + M.filter(rest) elseif cmd == 'undo' then M.undo_write() else diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 32cc2fb..286db9a 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -2,7 +2,7 @@ local config = require('pending.config') local parse = require('pending.parse') ---@class pending.LineMeta ----@field type 'task'|'header'|'blank' +---@field type 'task'|'header'|'blank'|'filter' ---@field id? integer ---@field due? string ---@field raw_due? string diff --git a/plugin/pending.lua b/plugin/pending.lua index 839b351..be546c5 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -164,10 +164,34 @@ end, { bar = true, nargs = '*', complete = function(arg_lead, cmd_line) - local subcmds = { 'add', 'archive', 'due', 'edit', 'sync', 'undo' } + local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'sync', 'undo' } if not cmd_line:match('^Pending%s+%S') then return filter_candidates(arg_lead, subcmds) end + if cmd_line:match('^Pending%s+filter') then + local after_filter = cmd_line:match('^Pending%s+filter%s+(.*)') or '' + local used = {} + for word in after_filter:gmatch('%S+') do + used[word] = true + end + local candidates = { 'clear', 'overdue', 'today', 'priority' } + local store = require('pending.store') + store.load() + local seen = {} + for _, task in ipairs(store.active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(candidates, 'cat:' .. task.category) + end + end + local filtered = {} + for _, c in ipairs(candidates) do + if not used[c] and (arg_lead == '' or c:find(arg_lead, 1, true) == 1) then + table.insert(filtered, c) + end + end + return filtered + end if cmd_line:match('^Pending%s+edit') then return complete_edit(arg_lead, cmd_line) end diff --git a/spec/filter_spec.lua b/spec/filter_spec.lua new file mode 100644 index 0000000..9038289 --- /dev/null +++ b/spec/filter_spec.lua @@ -0,0 +1,261 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') +local diff = require('pending.diff') + +describe('filter', function() + local tmpdir + local pending + local buffer + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + package.loaded['pending'] = nil + package.loaded['pending.buffer'] = nil + pending = require('pending') + buffer = require('pending.buffer') + buffer.set_filter({}, {}) + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + store.unload() + package.loaded['pending'] = nil + package.loaded['pending.buffer'] = nil + end) + + describe('filter predicates', function() + it('cat: hides tasks with non-matching category', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Home task', category = 'Home' }) + store.save() + pending.filter('cat:Work') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local work_task = nil + local home_task = nil + for _, t in ipairs(tasks) do + if t.category == 'Work' then work_task = t end + if t.category == 'Home' then home_task = t end + end + assert.is_not_nil(work_task) + assert.is_not_nil(home_task) + assert.is_nil(hidden[work_task.id]) + assert.is_true(hidden[home_task.id]) + end) + + it('cat: hides tasks with no category (default category)', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Inbox task' }) + store.save() + pending.filter('cat:Work') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local inbox_task = nil + for _, t in ipairs(tasks) do + if t.category ~= 'Work' then inbox_task = t end + end + assert.is_not_nil(inbox_task) + assert.is_true(hidden[inbox_task.id]) + end) + + it('overdue hides non-overdue tasks', function() + store.load() + store.add({ description = 'Old task', due = '2020-01-01' }) + store.add({ description = 'Future task', due = '2099-01-01' }) + store.add({ description = 'No due task' }) + store.save() + pending.filter('overdue') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local overdue_task, future_task, nodue_task + for _, t in ipairs(tasks) do + if t.due == '2020-01-01' then overdue_task = t end + if t.due == '2099-01-01' then future_task = t end + if not t.due then nodue_task = t end + end + assert.is_nil(hidden[overdue_task.id]) + assert.is_true(hidden[future_task.id]) + assert.is_true(hidden[nodue_task.id]) + end) + + it('today hides non-today tasks', function() + store.load() + local today = os.date('%Y-%m-%d') --[[@as string]] + store.add({ description = 'Today task', due = today }) + store.add({ description = 'Old task', due = '2020-01-01' }) + store.add({ description = 'Future task', due = '2099-01-01' }) + store.save() + pending.filter('today') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local today_task, old_task, future_task + for _, t in ipairs(tasks) do + if t.due == today then today_task = t end + if t.due == '2020-01-01' then old_task = t end + if t.due == '2099-01-01' then future_task = t end + end + assert.is_nil(hidden[today_task.id]) + assert.is_true(hidden[old_task.id]) + assert.is_true(hidden[future_task.id]) + end) + + it('priority hides non-priority tasks', function() + store.load() + store.add({ description = 'Important', priority = 1 }) + store.add({ description = 'Normal' }) + store.save() + pending.filter('priority') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local important_task, normal_task + for _, t in ipairs(tasks) do + if t.priority and t.priority > 0 then important_task = t end + if not t.priority or t.priority == 0 then normal_task = t end + end + assert.is_nil(hidden[important_task.id]) + assert.is_true(hidden[normal_task.id]) + end) + + it('multi-predicate AND: cat:Work + overdue', function() + store.load() + store.add({ description = 'Work overdue', category = 'Work', due = '2020-01-01' }) + store.add({ description = 'Work future', category = 'Work', due = '2099-01-01' }) + store.add({ description = 'Home overdue', category = 'Home', due = '2020-01-01' }) + store.save() + pending.filter('cat:Work overdue') + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local work_overdue, work_future, home_overdue + for _, t in ipairs(tasks) do + if t.description == 'Work overdue' then work_overdue = t end + if t.description == 'Work future' then work_future = t end + if t.description == 'Home overdue' then home_overdue = t end + end + assert.is_nil(hidden[work_overdue.id]) + assert.is_true(hidden[work_future.id]) + assert.is_true(hidden[home_overdue.id]) + end) + + it('filter clear removes all predicates and hidden ids', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Home task', category = 'Home' }) + store.save() + pending.filter('cat:Work') + assert.are.equal(1, #buffer.filter_predicates()) + pending.filter('clear') + assert.are.equal(0, #buffer.filter_predicates()) + assert.are.same({}, buffer.hidden_ids()) + end) + + it('filter empty string clears filter', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.save() + pending.filter('cat:Work') + assert.are.equal(1, #buffer.filter_predicates()) + pending.filter('') + assert.are.equal(0, #buffer.filter_predicates()) + end) + + it('filter predicates persist across set_filter calls', function() + store.load() + store.add({ description = 'Work task', category = 'Work' }) + store.add({ description = 'Home task', category = 'Home' }) + store.save() + pending.filter('cat:Work') + local preds = buffer.filter_predicates() + assert.are.equal(1, #preds) + assert.are.equal('cat:Work', preds[1]) + local hidden = buffer.hidden_ids() + local tasks = store.active_tasks() + local home_task + for _, t in ipairs(tasks) do + if t.category == 'Home' then home_task = t end + end + assert.is_true(hidden[home_task.id]) + end) + end) + + describe('diff.apply with hidden_ids', function() + it('does not mark hidden tasks as deleted', function() + store.load() + store.add({ description = 'Visible task' }) + store.add({ description = 'Hidden task' }) + store.save() + local tasks = store.active_tasks() + local hidden_task + for _, t in ipairs(tasks) do + if t.description == 'Hidden task' then hidden_task = t end + end + local hidden_ids = { [hidden_task.id] = true } + local lines = { + '/1/- [ ] Visible task', + } + diff.apply(lines, hidden_ids) + store.unload() + store.load() + local hidden = store.get(hidden_task.id) + assert.are.equal('pending', hidden.status) + end) + + it('marks tasks deleted when not hidden and not in buffer', function() + store.load() + store.add({ description = 'Keep task' }) + store.add({ description = 'Delete task' }) + store.save() + local tasks = store.active_tasks() + local keep_task, delete_task + for _, t in ipairs(tasks) do + if t.description == 'Keep task' then keep_task = t end + if t.description == 'Delete task' then delete_task = t end + end + local lines = { + '/' .. keep_task.id .. '/- [ ] Keep task', + } + diff.apply(lines, {}) + store.unload() + store.load() + local deleted = store.get(delete_task.id) + assert.are.equal('deleted', deleted.status) + end) + + it('strips FILTER: line before parsing', function() + store.load() + store.add({ description = 'My task' }) + store.save() + local tasks = store.active_tasks() + local task = tasks[1] + local lines = { + 'FILTER: cat:Work', + '/' .. task.id .. '/- [ ] My task', + } + diff.apply(lines, {}) + store.unload() + store.load() + local t = store.get(task.id) + assert.are.equal('pending', t.status) + end) + + it('parse_buffer skips FILTER: header line', function() + local lines = { + 'FILTER: overdue', + '/1/- [ ] A task', + } + local result = diff.parse_buffer(lines) + assert.are.equal(1, #result) + assert.are.equal('task', result[1].type) + assert.are.equal('A task', result[1].description) + end) + end) +end)