feat(filter): oil-like editable filter line (#43)

* feat(filter): oil-like editable filter line with predicate dispatch

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.

* ci: format
This commit is contained in:
Barrett Ruth 2026-02-26 18:29:56 -05:00 committed by GitHub
parent 3da23c924a
commit dcb6a4781d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 526 additions and 8 deletions

View file

@ -16,6 +16,10 @@ local current_view = nil
local _meta = {}
---@type table<integer, table<string, boolean>>
local _fold_state = {}
---@type string[]
local _filter_predicates = {}
---@type table<integer, true>
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<integer, true>
function M.hidden_ids()
return _hidden_ids
end
---@param predicates string[]
---@param hidden table<integer, true>
---@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)

View file

@ -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<integer, true>
---@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

View file

@ -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<integer, true>
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

View file

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