From 9af6086959fb2558e1c7053e6019f4f7cbf88638 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:18:34 -0500 Subject: [PATCH] feat(buffer): persist fold state across sessions (#94) Problem: folded category headers are lost when Neovim exits because `_fold_state` only lives in memory. Users must re-fold categories every session. Solution: store folded category names in the JSON data file as a top-level `folded_categories` field. On first render, `restore_folds` seeds from the store instead of the empty in-memory state. Folds are persisted on `M.close()` and `VimLeavePre`. --- lua/pending/buffer.lua | 66 +++++++++++++++++++++++++++++++++++++++++- lua/pending/init.lua | 12 ++++++++ lua/pending/store.lua | 15 ++++++++++ spec/store_spec.lua | 29 +++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 3d29128..5865fb4 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local log = require('pending.log') local views = require('pending.views') ---@class pending.buffer @@ -18,6 +19,8 @@ local current_view = nil local _meta = {} ---@type table> local _fold_state = {} +---@type boolean +local _initial_fold_loaded = false ---@type string[] local _filter_predicates = {} ---@type table @@ -89,12 +92,52 @@ function M.clear_marks(b) vim.api.nvim_buf_clear_namespace(b or task_bufnr, task_ns, 0, -1) end +---@return nil +function M.persist_folds() + log.debug(('persist_folds: view=%s store=%s'):format(tostring(current_view), tostring(_store ~= nil))) + if current_view ~= 'category' or not _store then + log.debug('persist_folds: early return (view or store)') + return + end + local bufnr = task_bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + log.debug('persist_folds: early return (no valid bufnr)') + return + end + local folded = {} + local seen = {} + local wins = vim.fn.win_findbuf(bufnr) + log.debug(('persist_folds: checking %d windows for bufnr=%d, meta has %d entries'):format(#wins, bufnr, #_meta)) + for _, winid in ipairs(wins) do + if vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_call(winid, function() + for lnum, m in ipairs(_meta) do + if m.type == 'header' and m.category and not seen[m.category] then + local closed = vim.fn.foldclosed(lnum) + log.debug(('persist_folds: win=%d lnum=%d cat=%s foldclosed=%d'):format(winid, lnum, m.category, closed)) + if closed ~= -1 then + seen[m.category] = true + table.insert(folded, m.category) + end + end + end + end) + end + end + log.debug(('persist_folds: saving %d folded categories: %s'):format(#folded, table.concat(folded, ', '))) + _store:set_folded_categories(folded) +end + ---@return nil function M.close() if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then task_winid = nil return end + M.persist_folds() + if _store then + _store:save() + end local wins = vim.api.nvim_list_wins() if #wins == 1 then vim.cmd.enew() @@ -275,7 +318,7 @@ local function snapshot_folds(bufnr) return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do - if _fold_state[winid] == nil then + if _fold_state[winid] == nil and _initial_fold_loaded then local state = {} vim.api.nvim_win_call(winid, function() for lnum, m in ipairs(_meta) do @@ -292,18 +335,39 @@ local function snapshot_folds(bufnr) end local function restore_folds(bufnr) + log.debug(('restore_folds: view=%s folding_enabled=%s'):format( + tostring(current_view), tostring(config.resolve_folding().enabled))) if current_view ~= 'category' or not config.resolve_folding().enabled then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local state = _fold_state[winid] _fold_state[winid] = nil + log.debug(('restore_folds: win=%d has_fold_state=%s initial_loaded=%s has_store=%s'):format( + winid, tostring(state ~= nil), tostring(_initial_fold_loaded), tostring(_store ~= nil))) + if not state and not _initial_fold_loaded and _store then + _initial_fold_loaded = true + local cats = _store:get_folded_categories() + log.debug(('restore_folds: loaded %d categories from store: %s'):format(#cats, table.concat(cats, ', '))) + if #cats > 0 then + state = {} + for _, cat in ipairs(cats) do + state[cat] = true + end + end + end if state and next(state) ~= nil then + local applying = {} + for k in pairs(state) do + table.insert(applying, k) + end + log.debug(('restore_folds: applying folds for: %s'):format(table.concat(applying, ', '))) vim.api.nvim_win_call(winid, function() vim.cmd('normal! zx') local saved = vim.api.nvim_win_get_cursor(0) for lnum, m in ipairs(_meta) do if m.type == 'header' and m.category and state[m.category] then + log.debug(('restore_folds: folding lnum=%d cat=%s'):format(lnum, m.category)) vim.api.nvim_win_set_cursor(0, { lnum, 0 }) vim.cmd('normal! zc') end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index ccb5cf5..d298062 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -268,6 +268,18 @@ function M._setup_autocmds(bufnr) end end, }) + vim.api.nvim_create_autocmd('VimLeavePre', { + group = group, + callback = function() + local bnr = buffer.bufnr() + log.debug(('VimLeavePre: bufnr=%s valid=%s'):format( + tostring(bnr), tostring(bnr and vim.api.nvim_buf_is_valid(bnr)))) + if bnr and vim.api.nvim_buf_is_valid(bnr) then + buffer.persist_folds() + get_store():save() + end + end, + }) end ---@param bufnr integer diff --git a/lua/pending/store.lua b/lua/pending/store.lua index ff68525..20898fd 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -20,6 +20,7 @@ local config = require('pending.config') ---@field next_id integer ---@field tasks pending.Task[] ---@field undo pending.Task[][] +---@field folded_categories string[] ---@class pending.Store ---@field path string @@ -39,6 +40,7 @@ local function empty_data() next_id = 1, tasks = {}, undo = {}, + folded_categories = {}, } end @@ -171,6 +173,7 @@ function Store:load() next_id = decoded.next_id or 1, tasks = {}, undo = {}, + folded_categories = decoded.folded_categories or {}, } for _, t in ipairs(decoded.tasks or {}) do table.insert(self._data.tasks, table_to_task(t)) @@ -199,6 +202,7 @@ function Store:save() next_id = self._data.next_id, tasks = {}, undo = {}, + folded_categories = self._data.folded_categories, } for _, task in ipairs(self._data.tasks) do table.insert(out.tasks, task_to_table(task)) @@ -371,6 +375,17 @@ function Store:set_next_id(id) self:data().next_id = id end +---@return string[] +function Store:get_folded_categories() + return self:data().folded_categories +end + +---@param cats string[] +---@return nil +function Store:set_folded_categories(cats) + self:data().folded_categories = cats +end + ---@return nil function Store:unload() self._data = nil diff --git a/spec/store_spec.lua b/spec/store_spec.lua index 0bed750..827dd21 100644 --- a/spec/store_spec.lua +++ b/spec/store_spec.lua @@ -214,6 +214,35 @@ describe('store', function() end) end) + describe('folded_categories', function() + it('defaults to empty table when missing from JSON', function() + local path = tmpdir .. '/tasks.json' + local f = io.open(path, 'w') + f:write(vim.json.encode({ + version = 1, + next_id = 1, + tasks = {}, + })) + f:close() + s:load() + assert.are.same({}, s:get_folded_categories()) + end) + + it('round-trips folded categories through save and load', function() + s:set_folded_categories({ 'Work', 'Home' }) + s:save() + s:load() + assert.are.same({ 'Work', 'Home' }, s:get_folded_categories()) + end) + + it('persists empty list', function() + s:set_folded_categories({}) + s:save() + s:load() + assert.are.same({}, s:get_folded_categories()) + end) + end) + describe('active_tasks', function() it('excludes deleted tasks', function() s:add({ description = 'Active' })