feat(views): add hide_done_categories config option (#153)

Problem: Categories where every task is done still render in the buffer,
cluttering the view when entire categories are finished.

Solution: Add `view.category.hide_done_categories` (boolean, default
false). When enabled, `category_view()` skips categories whose tasks are
all done/deleted, returns their IDs as `done_cat_hidden_ids`, and
`_on_write` merges those IDs into `hidden_ids` passed to `diff.apply()`
so hidden tasks are not mistakenly deleted on `:w`.
This commit is contained in:
Barrett Ruth 2026-03-12 20:19:27 -04:00 committed by Barrett Ruth
parent d18c0d3c82
commit 0707caf83c
6 changed files with 123 additions and 4 deletions

View file

@ -879,6 +879,16 @@ Fields: ~
`false` uses Vim's built-in foldtext.
Folds only apply to category view.
{hide_done_categories}
(boolean, default: false)
When true, categories where every task is
done (or deleted) are hidden from the
rendered buffer. The tasks remain in the
store and reappear when any task in the
category is un-done or a new pending task
is added. Hidden tasks are protected from
deletion on `:w`.
{queue} (table) *pending.QueueViewConfig*
Queue (priority) view settings.

View file

@ -27,6 +27,8 @@ local _filter_predicates = {}
---@type table<integer, true>
local _hidden_ids = {}
---@type table<integer, true>
local _done_cat_hidden_ids = {}
---@type table<integer, true>
local _dirty_rows = {}
---@type boolean
local _on_bytes_active = false
@ -74,6 +76,11 @@ function M.hidden_ids()
return _hidden_ids
end
---@return table<integer, true>
function M.done_cat_hidden_ids()
return _done_cat_hidden_ids
end
---@param predicates string[]
---@param hidden table<integer, true>
---@return nil
@ -694,10 +701,13 @@ function M.render(bufnr)
end
local lines, line_meta
_done_cat_hidden_ids = {}
if current_view == 'priority' then
lines, line_meta = views.priority_view(tasks)
else
lines, line_meta = views.category_view(tasks)
local done_cat_hidden
lines, line_meta, done_cat_hidden = views.category_view(tasks)
_done_cat_hidden_ids = done_cat_hidden
end
if #lines == 0 and #_filter_predicates == 0 then

View file

@ -84,6 +84,7 @@
---@class pending.CategoryViewConfig
---@field order? string[]
---@field folding? boolean|pending.FoldingConfig
---@field hide_done_categories? boolean
---@class pending.QueueViewConfig
@ -130,6 +131,7 @@ local defaults = {
category = {
order = {},
folding = true,
hide_done_categories = false,
},
queue = {},
},

View file

@ -509,6 +509,9 @@ function M._on_write(bufnr)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
for id in pairs(buffer.done_cat_hidden_ids()) do
hidden[id] = true
end
local new_refs = diff.apply(lines, s, hidden)
M._recompute_counts()
buffer.render(bufnr)

View file

@ -138,6 +138,7 @@ end
---@param tasks pending.Task[]
---@return string[] lines
---@return pending.LineMeta[] meta
---@return table<integer, true> done_cat_hidden_ids
function M.category_view(tasks)
local by_cat = {}
local cat_order = {}
@ -177,6 +178,9 @@ function M.category_view(tasks)
cat_order = ordered
end
local hide_done = config.get().view.category.hide_done_categories
local done_cat_hidden = {} ---@type table<integer, true>
for _, cat in ipairs(cat_order) do
sort_tasks(by_cat[cat])
sort_tasks(done_by_cat[cat])
@ -184,12 +188,21 @@ function M.category_view(tasks)
local lines = {}
local meta = {}
local rendered = 0
for i, cat in ipairs(cat_order) do
if i > 1 then
for _, cat in ipairs(cat_order) do
if hide_done and #by_cat[cat] == 0 and #done_by_cat[cat] > 0 then
for _, t in ipairs(done_by_cat[cat]) do
done_cat_hidden[t.id] = true
end
goto next_cat
end
if rendered > 0 then
table.insert(lines, '')
table.insert(meta, { type = 'blank' })
end
rendered = rendered + 1
table.insert(lines, '# ' .. cat)
table.insert(meta, { type = 'header', category = cat })
@ -220,9 +233,11 @@ function M.category_view(tasks)
forge_spans = compute_forge_spans(task, prefix_len),
})
end
::next_cat::
end
return lines, meta
return lines, meta, done_cat_hidden
end
---@param tasks pending.Task[]

View file

@ -280,6 +280,85 @@ describe('views', function()
assert.are.equal('# Alpha', headers[1])
assert.are.equal('# Beta', headers[2])
end)
it('returns empty done_cat_hidden_ids when hide_done_categories is false', function()
local t1 = s:add({ description = 'Done task', category = 'Work' })
s:update(t1.id, { status = 'done' })
s:add({ description = 'Active', category = 'Personal' })
local _, _, done_hidden = views.category_view(s:active_tasks())
assert.are.same({}, done_hidden)
end)
it('hides categories with only done tasks when hide_done_categories is true', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { category = { hide_done_categories = true } },
}
config.reset()
local t1 = s:add({ description = 'Done task', category = 'Work' })
s:update(t1.id, { status = 'done' })
s:add({ description = 'Active', category = 'Personal' })
local lines, meta, done_hidden = views.category_view(s:active_tasks())
local headers = {}
for i, m in ipairs(meta) do
if m.type == 'header' then
table.insert(headers, lines[i])
end
end
assert.are.equal(1, #headers)
assert.are.equal('# Personal', headers[1])
assert.are.same({ [t1.id] = true }, done_hidden)
end)
it('shows categories with a mix of done and pending tasks', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { category = { hide_done_categories = true } },
}
config.reset()
local t1 = s:add({ description = 'Done task', category = 'Work' })
s:update(t1.id, { status = 'done' })
s:add({ description = 'Active task', category = 'Work' })
local lines, meta, done_hidden = views.category_view(s:active_tasks())
local headers = {}
for i, m in ipairs(meta) do
if m.type == 'header' then
table.insert(headers, lines[i])
end
end
assert.are.equal(1, #headers)
assert.are.equal('# Work', headers[1])
assert.are.same({}, done_hidden)
end)
it('does not insert leading blank line when first category is hidden', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { category = { hide_done_categories = true } },
}
config.reset()
local t1 = s:add({ description = 'Done task', category = 'Alpha' })
s:update(t1.id, { status = 'done' })
s:add({ description = 'Active', category = 'Beta' })
local lines, meta = views.category_view(s:active_tasks())
assert.are.equal('header', meta[1].type)
assert.are.equal('# Beta', lines[1])
end)
it('returns all done task ids from hidden categories', function()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
view = { category = { hide_done_categories = true } },
}
config.reset()
local t1 = s:add({ description = 'Done A', category = 'Work' })
local t2 = s:add({ description = 'Done B', category = 'Work' })
s:update(t1.id, { status = 'done' })
s:update(t2.id, { status = 'done' })
s:add({ description = 'Active', category = 'Personal' })
local _, _, done_hidden = views.category_view(s:active_tasks())
assert.are.same({ [t1.id] = true, [t2.id] = true }, done_hidden)
end)
end)
describe('priority_view', function()