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`.
This commit is contained in:
parent
26d43688d0
commit
9af6086959
4 changed files with 121 additions and 1 deletions
|
|
@ -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<integer, table<string, boolean>>
|
||||
local _fold_state = {}
|
||||
---@type boolean
|
||||
local _initial_fold_loaded = false
|
||||
---@type string[]
|
||||
local _filter_predicates = {}
|
||||
---@type table<integer, true>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue