From 8d7759b6c4f70014e353815fe7ab43daa711cc3e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 12 Mar 2026 20:11:42 -0400 Subject: [PATCH] feat(views): add `hide_done_categories` config option 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`. Co-Authored-By: Claude Opus 4.6 --- doc/pending.txt | 10 ++++++ lua/pending/buffer.lua | 12 ++++++- lua/pending/config.lua | 2 ++ lua/pending/init.lua | 3 ++ lua/pending/views.lua | 21 +++++++++-- spec/views_spec.lua | 79 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 4 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 7270c8e..90448e4 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -877,6 +877,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. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index b731262..0bf3d64 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -27,6 +27,8 @@ local _filter_predicates = {} ---@type table local _hidden_ids = {} ---@type table +local _done_cat_hidden_ids = {} +---@type table local _dirty_rows = {} ---@type boolean local _on_bytes_active = false @@ -74,6 +76,11 @@ function M.hidden_ids() return _hidden_ids end +---@return table +function M.done_cat_hidden_ids() + return _done_cat_hidden_ids +end + ---@param predicates string[] ---@param hidden table ---@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 diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 4a5172e..dfeda8f 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -82,6 +82,7 @@ ---@class pending.CategoryViewConfig ---@field order? string[] ---@field folding? boolean|pending.FoldingConfig +---@field hide_done_categories? boolean ---@class pending.QueueViewConfig @@ -128,6 +129,7 @@ local defaults = { category = { order = {}, folding = true, + hide_done_categories = false, }, queue = {}, }, diff --git a/lua/pending/init.lua b/lua/pending/init.lua index aeba431..8777293 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -489,6 +489,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) diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 6fd1739..1b8e303 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -138,6 +138,7 @@ end ---@param tasks pending.Task[] ---@return string[] lines ---@return pending.LineMeta[] meta +---@return table 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 + 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[] diff --git a/spec/views_spec.lua b/spec/views_spec.lua index ff8ad93..115eb84 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -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()