diff --git a/lua/peekstack/config/validate/rules/persist.lua b/lua/peekstack/config/validate/rules/persist.lua index 3d26884..0c49303 100644 --- a/lua/peekstack/config/validate/rules/persist.lua +++ b/lua/peekstack/config/validate/rules/persist.lua @@ -4,6 +4,7 @@ local M = {} ---@type PeekstackConfigFieldRule[] local PERSIST_RULES = { + { key = "enabled", validate = shared.field_type("boolean") }, { key = "max_items", validate = shared.field_number_range({ min = 1 }) }, } @@ -34,9 +35,11 @@ function M.validate(cfg, defaults) shared.apply_rules(persist, "persist", defaults.persist, PERSIST_RULES) - local session = shared.as_table(persist.session) - if session then - shared.apply_rules(session, "persist.session", defaults.persist.session, PERSIST_SESSION_RULES) + if persist.session ~= nil then + local session = shared.ensure_table_field(persist, "session", "persist.session", defaults.persist.session) + if session then + shared.apply_rules(session, "persist.session", defaults.persist.session, PERSIST_SESSION_RULES) + end end if persist.auto ~= nil then diff --git a/lua/peekstack/config/validate/rules/providers.lua b/lua/peekstack/config/validate/rules/providers.lua index f0f418e..23299c7 100644 --- a/lua/peekstack/config/validate/rules/providers.lua +++ b/lua/peekstack/config/validate/rules/providers.lua @@ -2,8 +2,14 @@ local shared = require("peekstack.config.validate.shared") local M = {} +---@type PeekstackConfigFieldRule[] +local PROVIDER_ENABLE_RULES = { + { key = "enable", validate = shared.field_type("boolean") }, +} + ---@type PeekstackConfigFieldRule[] local MARKS_RULES = { + { key = "enable", validate = shared.field_type("boolean") }, { key = "include", validate = shared.field_type("string") }, { key = "include_special", validate = shared.field_type("boolean") }, } @@ -16,9 +22,20 @@ function M.validate(cfg, defaults) return end - local marks = shared.as_table(providers.marks) - if marks then - shared.apply_rules(marks, "providers.marks", defaults.providers.marks, MARKS_RULES) + for _, name in ipairs({ "lsp", "diagnostics", "file" }) do + if providers[name] ~= nil then + local provider = shared.ensure_table_field(providers, name, "providers." .. name, defaults.providers[name]) + if provider then + shared.apply_rules(provider, "providers." .. name, defaults.providers[name], PROVIDER_ENABLE_RULES) + end + end + end + + if providers.marks ~= nil then + local marks = shared.ensure_table_field(providers, "marks", "providers.marks", defaults.providers.marks) + if marks then + shared.apply_rules(marks, "providers.marks", defaults.providers.marks, MARKS_RULES) + end end end diff --git a/lua/peekstack/config/validate/rules/ui.lua b/lua/peekstack/config/validate/rules/ui.lua index bca831b..94b03c1 100644 --- a/lua/peekstack/config/validate/rules/ui.lua +++ b/lua/peekstack/config/validate/rules/ui.lua @@ -60,6 +60,37 @@ local QUICK_PEEK_RULES = { { key = "close_events", validate = shared.field_event_list() }, } +---@type PeekstackConfigFieldRule[] +local POPUP_AUTO_CLOSE_RULES = { + { key = "enabled", validate = shared.field_type("boolean") }, + { key = "idle_ms", validate = shared.field_number_range({ min = 1 }) }, + { key = "check_interval_ms", validate = shared.field_number_range({ min = 1 }) }, + { key = "ignore_pinned", validate = shared.field_type("boolean") }, +} + +---@type PeekstackConfigFieldRule[] +local FEEDBACK_RULES = { + { key = "highlight_origin_on_close", validate = shared.field_type("boolean") }, +} + +---@type PeekstackConfigFieldRule[] +local PROMOTE_RULES = { + { key = "close_popup", validate = shared.field_type("boolean") }, +} + +---@type PeekstackConfigFieldRule[] +local TITLE_RULES = { + { key = "enabled", validate = shared.field_type("boolean") }, + { key = "format", validate = shared.field_type("string") }, +} + +---@type PeekstackConfigFieldRule[] +local TITLE_CONTEXT_RULES = { + { key = "enabled", validate = shared.field_type("boolean") }, + { key = "max_depth", validate = shared.field_number_range({ min = 1 }) }, + { key = "separator", validate = shared.field_type("string") }, +} + ---@type PeekstackConfigFieldRule[] local TITLE_ICON_RULES = { { key = "enabled", validate = shared.field_type("boolean") }, @@ -119,28 +150,45 @@ end ---@param ui table ---@param defaults PeekstackConfigUI local function validate_popup(ui, defaults) - local popup = shared.as_table(ui.popup) + if ui.popup == nil then + return + end + local popup = shared.ensure_table_field(ui, "popup", "ui.popup", defaults.popup) if not popup then return end shared.apply_rules(popup, "ui.popup", defaults.popup, POPUP_RULES) - local source = shared.as_table(popup.source) - if source then - shared.apply_rules(source, "ui.popup.source", defaults.popup.source, POPUP_SOURCE_RULES) + if popup.source ~= nil then + local source = shared.ensure_table_field(popup, "source", "ui.popup.source", defaults.popup.source) + if source then + shared.apply_rules(source, "ui.popup.source", defaults.popup.source, POPUP_SOURCE_RULES) + end end - local history = shared.as_table(popup.history) - if history then - shared.apply_rules(history, "ui.popup.history", defaults.popup.history, POPUP_HISTORY_RULES) + if popup.history ~= nil then + local history = shared.ensure_table_field(popup, "history", "ui.popup.history", defaults.popup.history) + if history then + shared.apply_rules(history, "ui.popup.history", defaults.popup.history, POPUP_HISTORY_RULES) + end + end + + if popup.auto_close ~= nil then + local auto_close = shared.ensure_table_field(popup, "auto_close", "ui.popup.auto_close", defaults.popup.auto_close) + if auto_close then + shared.apply_rules(auto_close, "ui.popup.auto_close", defaults.popup.auto_close, POPUP_AUTO_CLOSE_RULES) + end end end ---@param ui table ---@param defaults PeekstackConfigUI local function validate_path(ui, defaults) - local path = shared.as_table(ui.path) + if ui.path == nil then + return + end + local path = shared.ensure_table_field(ui, "path", "ui.path", defaults.path) if path then shared.apply_rules(path, "ui.path", defaults.path, UI_PATH_RULES) end @@ -162,25 +210,34 @@ end ---@param ui table ---@param defaults PeekstackConfigUI local function validate_preview(ui, defaults) - local inline_preview = shared.as_table(ui.inline_preview) - if inline_preview then - shared.apply_rules(inline_preview, "ui.inline_preview", defaults.inline_preview, INLINE_PREVIEW_RULES) + if ui.inline_preview ~= nil then + local inline_preview = shared.ensure_table_field(ui, "inline_preview", "ui.inline_preview", defaults.inline_preview) + if inline_preview then + shared.apply_rules(inline_preview, "ui.inline_preview", defaults.inline_preview, INLINE_PREVIEW_RULES) + end end - local quick_peek = shared.as_table(ui.quick_peek) - if quick_peek then - shared.apply_rules(quick_peek, "ui.quick_peek", defaults.quick_peek, QUICK_PEEK_RULES) + if ui.quick_peek ~= nil then + local quick_peek = shared.ensure_table_field(ui, "quick_peek", "ui.quick_peek", defaults.quick_peek) + if quick_peek then + shared.apply_rules(quick_peek, "ui.quick_peek", defaults.quick_peek, QUICK_PEEK_RULES) + end end end ---@param ui table ---@param defaults PeekstackConfigTitle local function validate_title(ui, defaults) - local title = shared.as_table(ui.title) + if ui.title == nil then + return + end + local title = shared.ensure_table_field(ui, "title", "ui.title", defaults) if not title then return end + shared.apply_rules(title, "ui.title", defaults, TITLE_RULES) + if title.icons ~= nil and type(title.icons) ~= "table" then notify.warn("ui.title.icons must be a table, got " .. type(title.icons) .. ". Falling back to defaults") title.icons = vim.deepcopy(defaults.icons) @@ -195,6 +252,13 @@ local function validate_title(ui, defaults) icons.map = vim.deepcopy(defaults.icons.map) end end + + if title.context ~= nil then + local context = shared.ensure_table_field(title, "context", "ui.title.context", defaults.context) + if context then + shared.apply_rules(context, "ui.title.context", defaults.context, TITLE_CONTEXT_RULES) + end + end end ---@param ui table @@ -248,6 +312,20 @@ function M.validate(cfg, defaults) validate_preview(ui, defaults.ui) validate_title(ui, defaults.ui.title) validate_layout(ui, defaults.ui) + + if ui.feedback ~= nil then + local feedback = shared.ensure_table_field(ui, "feedback", "ui.feedback", defaults.ui.feedback) + if feedback then + shared.apply_rules(feedback, "ui.feedback", defaults.ui.feedback, FEEDBACK_RULES) + end + end + + if ui.promote ~= nil then + local promote = shared.ensure_table_field(ui, "promote", "ui.promote", defaults.ui.promote) + if promote then + shared.apply_rules(promote, "ui.promote", defaults.ui.promote, PROMOTE_RULES) + end + end end return M diff --git a/lua/peekstack/core/history.lua b/lua/peekstack/core/history.lua index 2cf26ba..a8dc786 100644 --- a/lua/peekstack/core/history.lua +++ b/lua/peekstack/core/history.lua @@ -19,6 +19,7 @@ end ---@return PeekstackHistoryEntry function M.build_entry(item, idx) return { + popup_id = item.id, location = item.location, title = item.title, title_chunks = item.title_chunks, @@ -52,16 +53,77 @@ local function emit_popup_event(event, popup_model, root_winid) user_events.emit(event, user_events.build_popup_data(popup_model, root_winid)) end +---Check whether a popup with the given id exists in the stack. +---@param stack PeekstackStackModel +---@param popup_id integer +---@return boolean +local function popup_exists_in_stack(stack, popup_id) + for _, p in ipairs(stack.popups) do + if p.id == popup_id then + return true + end + end + return false +end + +---Resolve parent_popup_id for a history entry being restored. +---Uses id_remap (for bulk restore) and verifies the target exists. +---@param stack PeekstackStackModel +---@param entry PeekstackHistoryEntry +---@param id_remap? table +---@return integer? +local function resolve_parent_id(stack, entry, id_remap) + local parent_id = entry.parent_popup_id + if not parent_id then + return nil + end + if id_remap and id_remap[parent_id] then + parent_id = id_remap[parent_id] + end + if popup_exists_in_stack(stack, parent_id) then + return parent_id + end + return nil +end + +---Return the stack-level id remap table, creating it if needed. +---@param stack PeekstackStackModel +---@return table +local function ensure_id_remap(stack) + if not stack._id_remap then + stack._id_remap = {} + end + return stack._id_remap +end + +---Record the mapping from old popup_id to new model id. +---@param stack PeekstackStackModel +---@param entry PeekstackHistoryEntry +---@param model PeekstackPopupModel +local function record_remap(stack, entry, model) + if entry.popup_id then + ensure_id_remap(stack)[entry.popup_id] = model.id + end +end + ---Restore a history entry into the stack. ---@param stack PeekstackStackModel ---@param entry PeekstackHistoryEntry +---@param id_remap? table extra old popup id -> new popup id mapping ---@return PeekstackPopupModel? -function M.restore_entry(stack, entry) +function M.restore_entry(stack, entry, id_remap) deps() + -- Merge stack-level remap with any caller-provided remap. + local merged = ensure_id_remap(stack) + if id_remap then + for k, v in pairs(id_remap) do + merged[k] = v + end + end local create_opts = { buffer_mode = entry.buffer_mode or "copy", origin_winid = stack.root_winid, - parent_popup_id = entry.parent_popup_id, + parent_popup_id = resolve_parent_id(stack, entry, merged), } -- Only pass title override for user-renamed popups (no title_chunks). -- Auto-generated titles are regenerated by build_title() to preserve @@ -112,6 +174,7 @@ function M.restore_last(stack) if not restored then return nil end + record_remap(stack, entry, restored) table.remove(stack.history) return restored end @@ -122,10 +185,15 @@ end function M.restore_all(stack) local restored = {} local remaining = {} + ---@type table + local id_remap = {} while #stack.history > 0 do local entry = table.remove(stack.history) - local model = M.restore_entry(stack, entry) - if model then + local model = M.restore_entry(stack, entry, id_remap) + if model and entry.popup_id then + id_remap[entry.popup_id] = model.id + table.insert(restored, model) + elseif model then table.insert(restored, model) else table.insert(remaining, 1, entry) @@ -148,6 +216,7 @@ function M.restore_from_history(stack, idx) if not restored then return nil end + record_remap(stack, entry, restored) table.remove(stack.history, idx) return restored end @@ -163,6 +232,7 @@ end ---@param stack PeekstackStackModel function M.clear(stack) stack.history = {} + stack._id_remap = nil end return M diff --git a/lua/peekstack/persist/service.lua b/lua/peekstack/persist/service.lua index 9a3b065..5ee9dda 100644 --- a/lua/peekstack/persist/service.lua +++ b/lua/peekstack/persist/service.lua @@ -75,6 +75,10 @@ local function collect_items(root_winid) title = popup.title, provider = popup.location.provider, ts = os.time(), + popup_id = popup.id, + pinned = popup.pinned or nil, + buffer_mode = popup.buffer_mode ~= "copy" and popup.buffer_mode or nil, + parent_popup_id = popup.parent_popup_id, } end @@ -213,13 +217,35 @@ function M.restore(name, opts) return end + ---@type table + local id_remap = {} for _, item in ipairs(session.items) do local loc = location.normalize({ uri = item.uri, range = item.range }, item.provider or "persist") if loc then - stack.push(loc, { + local parent_id = item.parent_popup_id + if parent_id then + if id_remap[parent_id] then + parent_id = id_remap[parent_id] + else + -- Parent was not restored (e.g. trimmed by max_items). + -- Drop the stale reference to avoid accidental collisions. + parent_id = nil + end + end + local model = stack.push(loc, { title = item.title, + buffer_mode = item.buffer_mode, + parent_popup_id = parent_id, defer_reflow = true, }) + if model then + if item.pinned then + model.pinned = true + end + if item.popup_id then + id_remap[item.popup_id] = model.id + end + end end end diff --git a/lua/peekstack/types.lua b/lua/peekstack/types.lua index 05e5437..5656ccc 100644 --- a/lua/peekstack/types.lua +++ b/lua/peekstack/types.lua @@ -26,6 +26,10 @@ ---@field title? string ---@field provider? string ---@field ts integer +---@field popup_id? integer +---@field pinned? boolean +---@field buffer_mode? "copy"|"source" +---@field parent_popup_id? integer ---@class PeekstackSessionMeta ---@field created_at integer @@ -126,6 +130,7 @@ ---@field display_col? integer ---@class PeekstackHistoryEntry +---@field popup_id integer ---@field location PeekstackLocation ---@field title? string ---@field title_chunks? PeekstackTitleChunk[] diff --git a/lua/peekstack/ui/stack_view/window.lua b/lua/peekstack/ui/stack_view/window.lua index 0afbfd8..91b59d3 100644 --- a/lua/peekstack/ui/stack_view/window.lua +++ b/lua/peekstack/ui/stack_view/window.lua @@ -49,20 +49,7 @@ end ---@return integer function M.find_root_winid() - local winid = vim.api.nvim_get_current_win() - local cfg = vim.api.nvim_win_get_config(winid) - if cfg.relative == "" then - return winid - end - - for _, candidate in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local candidate_cfg = vim.api.nvim_win_get_config(candidate) - if candidate_cfg.relative == "" then - return candidate - end - end - - return winid + return require("peekstack.core.stack").get_root_winid(vim.api.nvim_get_current_win()) end ---@return integer diff --git a/tests/config_spec.lua b/tests/config_spec.lua index 9f6ebc4..9d04851 100644 --- a/tests/config_spec.lua +++ b/tests/config_spec.lua @@ -462,11 +462,178 @@ describe("config", function() assert.equals("table", type(cfg.ui.title.icons.map)) end) + it("falls back on invalid auto_close config", function() + local cfg = config.setup({ + ui = { + popup = { + auto_close = { + enabled = "yes", + idle_ms = "slow", + check_interval_ms = false, + ignore_pinned = 0, + }, + }, + }, + }) + assert.is_true(has_message("ui.popup.auto_close.enabled")) + assert.is_true(has_message("ui.popup.auto_close.idle_ms")) + assert.is_true(has_message("ui.popup.auto_close.check_interval_ms")) + assert.is_true(has_message("ui.popup.auto_close.ignore_pinned")) + assert.equals(config.defaults.ui.popup.auto_close.enabled, cfg.ui.popup.auto_close.enabled) + assert.equals(config.defaults.ui.popup.auto_close.idle_ms, cfg.ui.popup.auto_close.idle_ms) + assert.equals(config.defaults.ui.popup.auto_close.check_interval_ms, cfg.ui.popup.auto_close.check_interval_ms) + assert.equals(config.defaults.ui.popup.auto_close.ignore_pinned, cfg.ui.popup.auto_close.ignore_pinned) + end) + + it("falls back on invalid feedback config", function() + local cfg = config.setup({ + ui = { + feedback = { + highlight_origin_on_close = "yes", + }, + }, + }) + assert.is_true(has_message("ui.feedback.highlight_origin_on_close")) + assert.equals(config.defaults.ui.feedback.highlight_origin_on_close, cfg.ui.feedback.highlight_origin_on_close) + end) + + it("falls back on invalid promote config", function() + local cfg = config.setup({ + ui = { + promote = { + close_popup = "yes", + }, + }, + }) + assert.is_true(has_message("ui.promote.close_popup")) + assert.equals(config.defaults.ui.promote.close_popup, cfg.ui.promote.close_popup) + end) + + it("falls back on invalid provider enable config", function() + local cfg = config.setup({ + providers = { + lsp = { enable = "yes" }, + diagnostics = { enable = 1 }, + file = { enable = "true" }, + marks = { enable = "no" }, + }, + }) + assert.is_true(has_message("providers.lsp.enable")) + assert.is_true(has_message("providers.diagnostics.enable")) + assert.is_true(has_message("providers.file.enable")) + assert.is_true(has_message("providers.marks.enable")) + assert.equals(config.defaults.providers.lsp.enable, cfg.providers.lsp.enable) + assert.equals(config.defaults.providers.diagnostics.enable, cfg.providers.diagnostics.enable) + assert.equals(config.defaults.providers.file.enable, cfg.providers.file.enable) + assert.equals(config.defaults.providers.marks.enable, cfg.providers.marks.enable) + end) + + it("falls back on invalid persist.enabled", function() + local cfg = config.setup({ + persist = { + enabled = "yes", + }, + }) + assert.is_true(has_message("persist.enabled")) + assert.equals(config.defaults.persist.enabled, cfg.persist.enabled) + end) + + it("falls back on invalid title top-level fields", function() + local cfg = config.setup({ + ui = { + title = { + enabled = "yes", + format = 123, + }, + }, + }) + assert.is_true(has_message("ui.title.enabled")) + assert.is_true(has_message("ui.title.format")) + assert.equals(config.defaults.ui.title.enabled, cfg.ui.title.enabled) + assert.equals(config.defaults.ui.title.format, cfg.ui.title.format) + end) + + it("falls back on invalid title context config", function() + local cfg = config.setup({ + ui = { + title = { + context = { + enabled = "yes", + max_depth = "deep", + separator = 123, + }, + }, + }, + }) + assert.is_true(has_message("ui.title.context.enabled")) + assert.is_true(has_message("ui.title.context.max_depth")) + assert.is_true(has_message("ui.title.context.separator")) + assert.equals(config.defaults.ui.title.context.enabled, cfg.ui.title.context.enabled) + assert.equals(config.defaults.ui.title.context.max_depth, cfg.ui.title.context.max_depth) + assert.equals(config.defaults.ui.title.context.separator, cfg.ui.title.context.separator) + end) + it("has icons enabled by default", function() local cfg = config.setup({}) assert.is_true(cfg.ui.title.icons.enabled) assert.equals("table", type(cfg.ui.title.icons.map)) assert.is_not_nil(cfg.ui.title.icons.map.lsp) end) + + it("falls back when subsection is a scalar instead of a table", function() + local cfg = config.setup({ + ui = { + feedback = false, + promote = "no", + popup = { + auto_close = true, + source = 1, + history = false, + }, + title = { + context = false, + }, + }, + providers = { + lsp = false, + marks = true, + }, + persist = { + session = "default", + }, + }) + + assert.is_true(has_message("ui.feedback must be a table")) + assert.equals("table", type(cfg.ui.feedback)) + assert.equals(config.defaults.ui.feedback.highlight_origin_on_close, cfg.ui.feedback.highlight_origin_on_close) + + assert.is_true(has_message("ui.promote must be a table")) + assert.equals("table", type(cfg.ui.promote)) + assert.equals(config.defaults.ui.promote.close_popup, cfg.ui.promote.close_popup) + + assert.is_true(has_message("ui.popup.auto_close must be a table")) + assert.equals("table", type(cfg.ui.popup.auto_close)) + assert.equals(config.defaults.ui.popup.auto_close.enabled, cfg.ui.popup.auto_close.enabled) + + assert.is_true(has_message("ui.popup.source must be a table")) + assert.equals("table", type(cfg.ui.popup.source)) + + assert.is_true(has_message("ui.popup.history must be a table")) + assert.equals("table", type(cfg.ui.popup.history)) + + assert.is_true(has_message("ui.title.context must be a table")) + assert.equals("table", type(cfg.ui.title.context)) + + assert.is_true(has_message("providers.lsp must be a table")) + assert.equals("table", type(cfg.providers.lsp)) + assert.equals(config.defaults.providers.lsp.enable, cfg.providers.lsp.enable) + + assert.is_true(has_message("providers.marks must be a table")) + assert.equals("table", type(cfg.providers.marks)) + assert.equals(config.defaults.providers.marks.enable, cfg.providers.marks.enable) + + assert.is_true(has_message("persist.session must be a table")) + assert.equals("table", type(cfg.persist.session)) + end) end) end) diff --git a/tests/history_spec.lua b/tests/history_spec.lua index 68aa37e..562d19d 100644 --- a/tests/history_spec.lua +++ b/tests/history_spec.lua @@ -187,6 +187,143 @@ describe("stack history", function() assert.equals(0, #stack.history_list()) end) + it("saves popup_id in history on close", function() + local loc = helpers.make_location() + local model = stack.push(loc) + assert.is_not_nil(model) + local original_id = model.id + + stack.close(model.id) + + local hist = stack.history_list() + assert.equals(1, #hist) + assert.equals(original_id, hist[1].popup_id) + end) + + it("restore_all remaps parent_popup_id for parent-child popups", function() + local loc = helpers.make_location() + local parent = stack.push(loc) + assert.is_not_nil(parent) + vim.api.nvim_set_current_win(parent.winid) + + local child = stack.push(loc) + assert.is_not_nil(child) + assert.equals(parent.id, child.parent_popup_id) + + local parent_original_id = parent.id + + -- Close child first, then parent (history stores newest last) + stack.close(child.id) + stack.close(parent.id) + assert.equals(2, #stack.history_list()) + + -- Restore all: parent is restored first (from end of history), + -- then child. Child's parent_popup_id should be remapped to the + -- new parent ID. + local restored = stack.restore_all() + assert.equals(2, #restored) + + -- Find the restored child (the one with a parent_popup_id) + local restored_child = nil + local restored_parent = nil + for _, r in ipairs(restored) do + if r.parent_popup_id then + restored_child = r + else + restored_parent = r + end + end + + assert.is_not_nil(restored_parent) + assert.is_not_nil(restored_child) + -- The parent_popup_id should point to the NEW parent id, not the old one + assert.equals(restored_parent.id, restored_child.parent_popup_id) + assert.is_not.equals(parent_original_id, restored_parent.id) + + -- Cleanup + for _, r in ipairs(restored) do + stack.close(r.id) + end + end) + + it("restore_last remaps parent_popup_id to new parent id", function() + local loc = helpers.make_location() + local parent = stack.push(loc) + assert.is_not_nil(parent) + vim.api.nvim_set_current_win(parent.winid) + + local child = stack.push(loc) + assert.is_not_nil(child) + local original_parent_id = parent.id + + -- Close both: child first, then parent + stack.close(child.id) + stack.close(parent.id) + assert.equals(2, #stack.history_list()) + + -- Restore parent first (last closed = last in history) + local restored_parent = stack.restore_last() + assert.is_not_nil(restored_parent) + assert.is_not.equals(original_parent_id, restored_parent.id) + + -- Restore child: its parent_popup_id should point to the NEW parent id + local restored_child = stack.restore_last() + assert.is_not_nil(restored_child) + assert.equals(restored_parent.id, restored_child.parent_popup_id) + + -- Cleanup + stack.close(restored_child.id) + stack.close(restored_parent.id) + end) + + it("restore_last drops orphaned parent_popup_id", function() + local loc = helpers.make_location() + local parent = stack.push(loc) + assert.is_not_nil(parent) + vim.api.nvim_set_current_win(parent.winid) + + local child = stack.push(loc) + assert.is_not_nil(child) + + -- Close both + stack.close(child.id) + stack.close(parent.id) + + -- Only restore child (skip parent). Parent is gone so + -- child's parent_popup_id should be nil. + -- History is LIFO: parent is last, so remove it first without restoring + table.remove(stack.history_list()) + + local restored = stack.restore_last() + assert.is_not_nil(restored) + assert.is_nil(restored.parent_popup_id) + + stack.close(restored.id) + end) + + it("restore_from_history remaps parent_popup_id using id_remap", function() + local loc = helpers.make_location() + local parent = stack.push(loc) + assert.is_not_nil(parent) + vim.api.nvim_set_current_win(parent.winid) + + local child = stack.push(loc) + assert.is_not_nil(child) + + -- Close child only + stack.close(child.id) + assert.equals(1, #stack.history_list()) + + -- Parent still exists, so parent_popup_id should remain valid + local restored = stack.restore_from_history(1) + assert.is_not_nil(restored) + assert.equals(parent.id, restored.parent_popup_id) + + -- Cleanup + stack.close(restored.id) + stack.close(parent.id) + end) + it("respects history max_items from config", function() config.setup({ ui = { popup = { history = { max_items = 2 } } } }) @@ -206,6 +343,7 @@ end) describe("history.build_entry", function() it("builds entry from popup model", function() local item = { + id = 7, location = { uri = "file:///test.lua", range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } }, @@ -222,6 +360,7 @@ describe("history.build_entry", function() local entry = history.build_entry(item, 3) + assert.equals(7, entry.popup_id) assert.same(item.location, entry.location) assert.equals("test title", entry.title) assert.same({ { "test", "HL" } }, entry.title_chunks) diff --git a/tests/persist_sessions_spec.lua b/tests/persist_sessions_spec.lua index 623c1a0..ff88701 100644 --- a/tests/persist_sessions_spec.lua +++ b/tests/persist_sessions_spec.lua @@ -633,6 +633,238 @@ describe("peekstack.persist.sessions", function() vim.notify = original_notify end) + it("should save pinned and buffer_mode in session items", function() + local path = make_file("pinned_mode", { "line1", "line2" }) + local loc = make_location(path, 0) + local model = stack.push(loc, { title = "Pinned" }) + assert.is_not_nil(model) + model.pinned = true + + local source_model = stack.push(loc, { title = "Source", buffer_mode = "source" }) + assert.is_not_nil(source_model) + + persist.save_current("pinned_session", { silent = true, sync = true }) + + local data = migrate.ensure(read_and_wait(test_scope)) + local items = data.sessions.pinned_session.items + assert.equals(2, #items) + assert.is_true(items[1].pinned) + assert.equals("source", items[2].buffer_mode) + end) + + it("should save parent_popup_id in session items", function() + local path = make_file("parent_child", { "line1", "line2" }) + local parent = stack.push(make_location(path, 0), { title = "Parent" }) + assert.is_not_nil(parent) + vim.api.nvim_set_current_win(parent.winid) + + local child = stack.push(make_location(path, 1), { title = "Child" }) + assert.is_not_nil(child) + assert.equals(parent.id, child.parent_popup_id) + + persist.save_current("parent_child_session", { silent = true, sync = true }) + + local data = migrate.ensure(read_and_wait(test_scope)) + local items = data.sessions.parent_child_session.items + assert.equals(2, #items) + assert.is_not_nil(items[1].popup_id) + assert.equals(items[1].popup_id, items[2].parent_popup_id) + end) + + it("should restore pinned and buffer_mode from session", function() + local original_push = stack.push + local original_reflow = stack.reflow + + write_and_wait(test_scope, { + version = 2, + sessions = { + restore_fields = { + items = { + { + uri = "file:///tmp/a.lua", + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, + provider = "test", + ts = os.time(), + pinned = true, + buffer_mode = "source", + popup_id = 10, + }, + }, + meta = { created_at = os.time(), updated_at = os.time() }, + }, + }, + }) + + local pushed_opts = {} + local pushed_models = {} + + local ok, err = pcall(function() + stack.push = function(_loc, opts) + table.insert(pushed_opts, vim.deepcopy(opts or {})) + local model = { id = #pushed_opts } + table.insert(pushed_models, model) + return model + end + stack.reflow = function() end + + local restored = nil + persist.restore("restore_fields", { + silent = true, + on_done = function(result) + restored = result + end, + }) + + local waited = vim.wait(wait_timeout_ms, function() + return restored ~= nil + end, wait_interval_ms) + assert.is_true(waited, "Timed out waiting for restore callback") + + assert.is_true(restored) + assert.equals(1, #pushed_opts) + assert.equals("source", pushed_opts[1].buffer_mode) + assert.is_true(pushed_models[1].pinned) + end) + + stack.push = original_push + stack.reflow = original_reflow + + if not ok then + error(err) + end + end) + + it("should remap parent_popup_id when restoring session", function() + local original_push = stack.push + local original_reflow = stack.reflow + + write_and_wait(test_scope, { + version = 2, + sessions = { + remap_parent = { + items = { + { + uri = "file:///tmp/parent.lua", + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, + provider = "test", + ts = os.time(), + popup_id = 100, + }, + { + uri = "file:///tmp/child.lua", + range = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 1 } }, + provider = "test", + ts = os.time(), + popup_id = 101, + parent_popup_id = 100, + }, + }, + meta = { created_at = os.time(), updated_at = os.time() }, + }, + }, + }) + + local pushed_opts = {} + local next_model_id = 200 + + local ok, err = pcall(function() + stack.push = function(_loc, opts) + table.insert(pushed_opts, vim.deepcopy(opts or {})) + local model = { id = next_model_id } + next_model_id = next_model_id + 1 + return model + end + stack.reflow = function() end + + local restored = nil + persist.restore("remap_parent", { + silent = true, + on_done = function(result) + restored = result + end, + }) + + local waited = vim.wait(wait_timeout_ms, function() + return restored ~= nil + end, wait_interval_ms) + assert.is_true(waited, "Timed out waiting for restore callback") + + assert.is_true(restored) + assert.equals(2, #pushed_opts) + -- First item (parent) should have no parent_popup_id + assert.is_nil(pushed_opts[1].parent_popup_id) + -- Second item (child) should have remapped parent_popup_id (200, not 100) + assert.equals(200, pushed_opts[2].parent_popup_id) + end) + + stack.push = original_push + stack.reflow = original_reflow + + if not ok then + error(err) + end + end) + + it("should drop orphaned parent_popup_id when parent is not in session", function() + local original_push = stack.push + local original_reflow = stack.reflow + + -- Session where the parent (popup_id=50) is NOT present in items + write_and_wait(test_scope, { + version = 2, + sessions = { + orphan_parent = { + items = { + { + uri = "file:///tmp/orphan.lua", + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, + provider = "test", + ts = os.time(), + popup_id = 51, + parent_popup_id = 50, + }, + }, + meta = { created_at = os.time(), updated_at = os.time() }, + }, + }, + }) + + local pushed_opts = {} + + local ok, err = pcall(function() + stack.push = function(_loc, opts) + table.insert(pushed_opts, vim.deepcopy(opts or {})) + return { id = 999 } + end + stack.reflow = function() end + + local restored = nil + persist.restore("orphan_parent", { + silent = true, + on_done = function(result) + restored = result + end, + }) + + local waited = vim.wait(wait_timeout_ms, function() + return restored ~= nil + end, wait_interval_ms) + assert.is_true(waited, "Timed out waiting for restore callback") + + assert.is_true(restored) + assert.equals(1, #pushed_opts) + -- parent_popup_id should be nil (orphaned), not 50 + assert.is_nil(pushed_opts[1].parent_popup_id) + end) + + stack.push = original_push + stack.reflow = original_reflow + + if not ok then + error(err) + end + end) + it("should save the root stack when stack view is active", function() local stack_view = require("peekstack.ui.stack_view") diff --git a/tests/stack_view_spec.lua b/tests/stack_view_spec.lua index aed83f3..45d9f06 100644 --- a/tests/stack_view_spec.lua +++ b/tests/stack_view_spec.lua @@ -298,6 +298,32 @@ describe("peekstack.ui.stack_view", function() end end) + it("resolves correct root winid from a floating popup window", function() + local window = require("peekstack.ui.stack_view.window") + + -- Create two splits + local win_a = vim.api.nvim_get_current_win() + vim.api.nvim_cmd({ cmd = "vsplit" }, {}) + local win_b = vim.api.nvim_get_current_win() + + -- Push a popup from win_a + vim.api.nvim_set_current_win(win_a) + local model = stack.push(helpers.make_location()) + assert.is_not_nil(model) + + -- Focus the popup (a floating window) + vim.api.nvim_set_current_win(model.winid) + + -- find_root_winid should resolve to win_a (the root that owns the popup), + -- not to win_b (an unrelated split) + local resolved = window.find_root_winid() + assert.equals(win_a, resolved) + + -- Cleanup + stack.close(model.id) + pcall(vim.api.nvim_win_close, win_b, true) + end) + it("closes help when focus leaves the help window", function() stack_view.open() local context = stack_view_context()