feat(buffer): persist fold state across sessions
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
5a4cc7f8a1
4 changed files with 121 additions and 1 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
local config = require('pending.config')
|
local config = require('pending.config')
|
||||||
|
local log = require('pending.log')
|
||||||
local views = require('pending.views')
|
local views = require('pending.views')
|
||||||
|
|
||||||
---@class pending.buffer
|
---@class pending.buffer
|
||||||
|
|
@ -18,6 +19,8 @@ local current_view = nil
|
||||||
local _meta = {}
|
local _meta = {}
|
||||||
---@type table<integer, table<string, boolean>>
|
---@type table<integer, table<string, boolean>>
|
||||||
local _fold_state = {}
|
local _fold_state = {}
|
||||||
|
---@type boolean
|
||||||
|
local _initial_fold_loaded = false
|
||||||
---@type string[]
|
---@type string[]
|
||||||
local _filter_predicates = {}
|
local _filter_predicates = {}
|
||||||
---@type table<integer, true>
|
---@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)
|
vim.api.nvim_buf_clear_namespace(b or task_bufnr, task_ns, 0, -1)
|
||||||
end
|
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
|
---@return nil
|
||||||
function M.close()
|
function M.close()
|
||||||
if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then
|
if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then
|
||||||
task_winid = nil
|
task_winid = nil
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
M.persist_folds()
|
||||||
|
if _store then
|
||||||
|
_store:save()
|
||||||
|
end
|
||||||
local wins = vim.api.nvim_list_wins()
|
local wins = vim.api.nvim_list_wins()
|
||||||
if #wins == 1 then
|
if #wins == 1 then
|
||||||
vim.cmd.enew()
|
vim.cmd.enew()
|
||||||
|
|
@ -275,7 +318,7 @@ local function snapshot_folds(bufnr)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
|
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 = {}
|
local state = {}
|
||||||
vim.api.nvim_win_call(winid, function()
|
vim.api.nvim_win_call(winid, function()
|
||||||
for lnum, m in ipairs(_meta) do
|
for lnum, m in ipairs(_meta) do
|
||||||
|
|
@ -292,18 +335,39 @@ local function snapshot_folds(bufnr)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function restore_folds(bufnr)
|
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
|
if current_view ~= 'category' or not config.resolve_folding().enabled then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
|
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
|
||||||
local state = _fold_state[winid]
|
local state = _fold_state[winid]
|
||||||
_fold_state[winid] = nil
|
_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
|
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.api.nvim_win_call(winid, function()
|
||||||
vim.cmd('normal! zx')
|
vim.cmd('normal! zx')
|
||||||
local saved = vim.api.nvim_win_get_cursor(0)
|
local saved = vim.api.nvim_win_get_cursor(0)
|
||||||
for lnum, m in ipairs(_meta) do
|
for lnum, m in ipairs(_meta) do
|
||||||
if m.type == 'header' and m.category and state[m.category] then
|
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.api.nvim_win_set_cursor(0, { lnum, 0 })
|
||||||
vim.cmd('normal! zc')
|
vim.cmd('normal! zc')
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,18 @@ function M._setup_autocmds(bufnr)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ local config = require('pending.config')
|
||||||
---@field next_id integer
|
---@field next_id integer
|
||||||
---@field tasks pending.Task[]
|
---@field tasks pending.Task[]
|
||||||
---@field undo pending.Task[][]
|
---@field undo pending.Task[][]
|
||||||
|
---@field folded_categories string[]
|
||||||
|
|
||||||
---@class pending.Store
|
---@class pending.Store
|
||||||
---@field path string
|
---@field path string
|
||||||
|
|
@ -39,6 +40,7 @@ local function empty_data()
|
||||||
next_id = 1,
|
next_id = 1,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
undo = {},
|
undo = {},
|
||||||
|
folded_categories = {},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -171,6 +173,7 @@ function Store:load()
|
||||||
next_id = decoded.next_id or 1,
|
next_id = decoded.next_id or 1,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
undo = {},
|
undo = {},
|
||||||
|
folded_categories = decoded.folded_categories or {},
|
||||||
}
|
}
|
||||||
for _, t in ipairs(decoded.tasks or {}) do
|
for _, t in ipairs(decoded.tasks or {}) do
|
||||||
table.insert(self._data.tasks, table_to_task(t))
|
table.insert(self._data.tasks, table_to_task(t))
|
||||||
|
|
@ -199,6 +202,7 @@ function Store:save()
|
||||||
next_id = self._data.next_id,
|
next_id = self._data.next_id,
|
||||||
tasks = {},
|
tasks = {},
|
||||||
undo = {},
|
undo = {},
|
||||||
|
folded_categories = self._data.folded_categories,
|
||||||
}
|
}
|
||||||
for _, task in ipairs(self._data.tasks) do
|
for _, task in ipairs(self._data.tasks) do
|
||||||
table.insert(out.tasks, task_to_table(task))
|
table.insert(out.tasks, task_to_table(task))
|
||||||
|
|
@ -371,6 +375,17 @@ function Store:set_next_id(id)
|
||||||
self:data().next_id = id
|
self:data().next_id = id
|
||||||
end
|
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
|
---@return nil
|
||||||
function Store:unload()
|
function Store:unload()
|
||||||
self._data = nil
|
self._data = nil
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,35 @@ describe('store', function()
|
||||||
end)
|
end)
|
||||||
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()
|
describe('active_tasks', function()
|
||||||
it('excludes deleted tasks', function()
|
it('excludes deleted tasks', function()
|
||||||
s:add({ description = 'Active' })
|
s:add({ description = 'Active' })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue