Skip to content

Commit 06f6252

Browse files
committed
feat(persist): save pinned, buffer_mode, and parent-child links in sessions
Extend session items to include popup_id, pinned, buffer_mode, and parent_popup_id so that restored sessions faithfully reproduce the original exploration flow. Parent IDs are remapped during restore to point to newly created popup IDs.
1 parent c6f2769 commit 06f6252

3 files changed

Lines changed: 263 additions & 1 deletion

File tree

lua/peekstack/persist/service.lua

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ local function collect_items(root_winid)
7575
title = popup.title,
7676
provider = popup.location.provider,
7777
ts = os.time(),
78+
popup_id = popup.id,
79+
pinned = popup.pinned or nil,
80+
buffer_mode = popup.buffer_mode ~= "copy" and popup.buffer_mode or nil,
81+
parent_popup_id = popup.parent_popup_id,
7882
}
7983
end
8084

@@ -213,13 +217,35 @@ function M.restore(name, opts)
213217
return
214218
end
215219

220+
---@type table<integer, integer>
221+
local id_remap = {}
216222
for _, item in ipairs(session.items) do
217223
local loc = location.normalize({ uri = item.uri, range = item.range }, item.provider or "persist")
218224
if loc then
219-
stack.push(loc, {
225+
local parent_id = item.parent_popup_id
226+
if parent_id then
227+
if id_remap[parent_id] then
228+
parent_id = id_remap[parent_id]
229+
else
230+
-- Parent was not restored (e.g. trimmed by max_items).
231+
-- Drop the stale reference to avoid accidental collisions.
232+
parent_id = nil
233+
end
234+
end
235+
local model = stack.push(loc, {
220236
title = item.title,
237+
buffer_mode = item.buffer_mode,
238+
parent_popup_id = parent_id,
221239
defer_reflow = true,
222240
})
241+
if model then
242+
if item.pinned then
243+
model.pinned = true
244+
end
245+
if item.popup_id then
246+
id_remap[item.popup_id] = model.id
247+
end
248+
end
223249
end
224250
end
225251

lua/peekstack/types.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
---@field title? string
2727
---@field provider? string
2828
---@field ts integer
29+
---@field popup_id? integer
30+
---@field pinned? boolean
31+
---@field buffer_mode? "copy"|"source"
32+
---@field parent_popup_id? integer
2933

3034
---@class PeekstackSessionMeta
3135
---@field created_at integer

tests/persist_sessions_spec.lua

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,238 @@ describe("peekstack.persist.sessions", function()
633633
vim.notify = original_notify
634634
end)
635635

636+
it("should save pinned and buffer_mode in session items", function()
637+
local path = make_file("pinned_mode", { "line1", "line2" })
638+
local loc = make_location(path, 0)
639+
local model = stack.push(loc, { title = "Pinned" })
640+
assert.is_not_nil(model)
641+
model.pinned = true
642+
643+
local source_model = stack.push(loc, { title = "Source", buffer_mode = "source" })
644+
assert.is_not_nil(source_model)
645+
646+
persist.save_current("pinned_session", { silent = true, sync = true })
647+
648+
local data = migrate.ensure(read_and_wait(test_scope))
649+
local items = data.sessions.pinned_session.items
650+
assert.equals(2, #items)
651+
assert.is_true(items[1].pinned)
652+
assert.equals("source", items[2].buffer_mode)
653+
end)
654+
655+
it("should save parent_popup_id in session items", function()
656+
local path = make_file("parent_child", { "line1", "line2" })
657+
local parent = stack.push(make_location(path, 0), { title = "Parent" })
658+
assert.is_not_nil(parent)
659+
vim.api.nvim_set_current_win(parent.winid)
660+
661+
local child = stack.push(make_location(path, 1), { title = "Child" })
662+
assert.is_not_nil(child)
663+
assert.equals(parent.id, child.parent_popup_id)
664+
665+
persist.save_current("parent_child_session", { silent = true, sync = true })
666+
667+
local data = migrate.ensure(read_and_wait(test_scope))
668+
local items = data.sessions.parent_child_session.items
669+
assert.equals(2, #items)
670+
assert.is_not_nil(items[1].popup_id)
671+
assert.equals(items[1].popup_id, items[2].parent_popup_id)
672+
end)
673+
674+
it("should restore pinned and buffer_mode from session", function()
675+
local original_push = stack.push
676+
local original_reflow = stack.reflow
677+
678+
write_and_wait(test_scope, {
679+
version = 2,
680+
sessions = {
681+
restore_fields = {
682+
items = {
683+
{
684+
uri = "file:///tmp/a.lua",
685+
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } },
686+
provider = "test",
687+
ts = os.time(),
688+
pinned = true,
689+
buffer_mode = "source",
690+
popup_id = 10,
691+
},
692+
},
693+
meta = { created_at = os.time(), updated_at = os.time() },
694+
},
695+
},
696+
})
697+
698+
local pushed_opts = {}
699+
local pushed_models = {}
700+
701+
local ok, err = pcall(function()
702+
stack.push = function(_loc, opts)
703+
table.insert(pushed_opts, vim.deepcopy(opts or {}))
704+
local model = { id = #pushed_opts }
705+
table.insert(pushed_models, model)
706+
return model
707+
end
708+
stack.reflow = function() end
709+
710+
local restored = nil
711+
persist.restore("restore_fields", {
712+
silent = true,
713+
on_done = function(result)
714+
restored = result
715+
end,
716+
})
717+
718+
local waited = vim.wait(wait_timeout_ms, function()
719+
return restored ~= nil
720+
end, wait_interval_ms)
721+
assert.is_true(waited, "Timed out waiting for restore callback")
722+
723+
assert.is_true(restored)
724+
assert.equals(1, #pushed_opts)
725+
assert.equals("source", pushed_opts[1].buffer_mode)
726+
assert.is_true(pushed_models[1].pinned)
727+
end)
728+
729+
stack.push = original_push
730+
stack.reflow = original_reflow
731+
732+
if not ok then
733+
error(err)
734+
end
735+
end)
736+
737+
it("should remap parent_popup_id when restoring session", function()
738+
local original_push = stack.push
739+
local original_reflow = stack.reflow
740+
741+
write_and_wait(test_scope, {
742+
version = 2,
743+
sessions = {
744+
remap_parent = {
745+
items = {
746+
{
747+
uri = "file:///tmp/parent.lua",
748+
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } },
749+
provider = "test",
750+
ts = os.time(),
751+
popup_id = 100,
752+
},
753+
{
754+
uri = "file:///tmp/child.lua",
755+
range = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 1 } },
756+
provider = "test",
757+
ts = os.time(),
758+
popup_id = 101,
759+
parent_popup_id = 100,
760+
},
761+
},
762+
meta = { created_at = os.time(), updated_at = os.time() },
763+
},
764+
},
765+
})
766+
767+
local pushed_opts = {}
768+
local next_model_id = 200
769+
770+
local ok, err = pcall(function()
771+
stack.push = function(_loc, opts)
772+
table.insert(pushed_opts, vim.deepcopy(opts or {}))
773+
local model = { id = next_model_id }
774+
next_model_id = next_model_id + 1
775+
return model
776+
end
777+
stack.reflow = function() end
778+
779+
local restored = nil
780+
persist.restore("remap_parent", {
781+
silent = true,
782+
on_done = function(result)
783+
restored = result
784+
end,
785+
})
786+
787+
local waited = vim.wait(wait_timeout_ms, function()
788+
return restored ~= nil
789+
end, wait_interval_ms)
790+
assert.is_true(waited, "Timed out waiting for restore callback")
791+
792+
assert.is_true(restored)
793+
assert.equals(2, #pushed_opts)
794+
-- First item (parent) should have no parent_popup_id
795+
assert.is_nil(pushed_opts[1].parent_popup_id)
796+
-- Second item (child) should have remapped parent_popup_id (200, not 100)
797+
assert.equals(200, pushed_opts[2].parent_popup_id)
798+
end)
799+
800+
stack.push = original_push
801+
stack.reflow = original_reflow
802+
803+
if not ok then
804+
error(err)
805+
end
806+
end)
807+
808+
it("should drop orphaned parent_popup_id when parent is not in session", function()
809+
local original_push = stack.push
810+
local original_reflow = stack.reflow
811+
812+
-- Session where the parent (popup_id=50) is NOT present in items
813+
write_and_wait(test_scope, {
814+
version = 2,
815+
sessions = {
816+
orphan_parent = {
817+
items = {
818+
{
819+
uri = "file:///tmp/orphan.lua",
820+
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } },
821+
provider = "test",
822+
ts = os.time(),
823+
popup_id = 51,
824+
parent_popup_id = 50,
825+
},
826+
},
827+
meta = { created_at = os.time(), updated_at = os.time() },
828+
},
829+
},
830+
})
831+
832+
local pushed_opts = {}
833+
834+
local ok, err = pcall(function()
835+
stack.push = function(_loc, opts)
836+
table.insert(pushed_opts, vim.deepcopy(opts or {}))
837+
return { id = 999 }
838+
end
839+
stack.reflow = function() end
840+
841+
local restored = nil
842+
persist.restore("orphan_parent", {
843+
silent = true,
844+
on_done = function(result)
845+
restored = result
846+
end,
847+
})
848+
849+
local waited = vim.wait(wait_timeout_ms, function()
850+
return restored ~= nil
851+
end, wait_interval_ms)
852+
assert.is_true(waited, "Timed out waiting for restore callback")
853+
854+
assert.is_true(restored)
855+
assert.equals(1, #pushed_opts)
856+
-- parent_popup_id should be nil (orphaned), not 50
857+
assert.is_nil(pushed_opts[1].parent_popup_id)
858+
end)
859+
860+
stack.push = original_push
861+
stack.reflow = original_reflow
862+
863+
if not ok then
864+
error(err)
865+
end
866+
end)
867+
636868
it("should save the root stack when stack view is active", function()
637869
local stack_view = require("peekstack.ui.stack_view")
638870

0 commit comments

Comments
 (0)