diff --git a/README.md b/README.md index c601d53..cec1667 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,15 @@ Using lazy.nvim: }) -- LSP: peek at definitions and references - vim.keymap.set("n", "pd", function() peekstack.peek.definition() end) - vim.keymap.set("n", "pr", function() peekstack.peek.references() end) + vim.keymap.set("n", "pd", function() peekstack.peek.definition() end) + vim.keymap.set("n", "pr", function() peekstack.peek.references() end) -- Diagnostics & files: peek at diagnostics or files under cursor - vim.keymap.set("n", "pl", function() peekstack.peek.diagnostics_cursor() end) - vim.keymap.set("n", "pf", function() peekstack.peek.file_under_cursor() end) + vim.keymap.set("n", "pl", function() peekstack.peek.diagnostics_cursor() end) + vim.keymap.set("n", "pf", function() peekstack.peek.file_under_cursor() end) -- Marks: browse buffer marks (requires marks provider enabled) - vim.keymap.set("n", "pm", function() peekstack.peek.marks_buffer() end) + vim.keymap.set("n", "pm", function() peekstack.peek.marks_buffer() end) end, } ``` diff --git a/lua/peekstack/config.lua b/lua/peekstack/config.lua index 81d2ac8..6679e62 100644 --- a/lua/peekstack/config.lua +++ b/lua/peekstack/config.lua @@ -199,17 +199,35 @@ local config = vim.deepcopy(M.defaults) ---@param path string ---@param value any -local function validate_event_list(path, value) +---@param default string[] +---@return string[] +local function sanitize_event_list(path, value, default) if type(value) ~= "table" then - notify.warn(string.format("%s must be a list of strings", path)) - return + notify.warn(string.format("%s must be a list of strings. Falling back to defaults", path)) + return vim.deepcopy(default) end - for idx, event in ipairs(value) do - if type(event) ~= "string" then - notify.warn(string.format("%s[%d] must be a string, got %s", path, idx, type(event))) - return + + ---@type string[] + local events = {} + local invalid_count = 0 + for _, event in ipairs(value) do + if type(event) == "string" and event ~= "" then + events[#events + 1] = event + else + invalid_count = invalid_count + 1 end end + + if invalid_count > 0 then + notify.warn(string.format("%s contains %d invalid entries. Ignoring invalid values", path, invalid_count)) + end + + if #events == 0 then + notify.warn(string.format("%s must contain at least one valid event. Falling back to defaults", path)) + return vim.deepcopy(default) + end + + return events end ---@alias PeekstackConfigFieldValidator fun(path: string, value: any, default: any): any @@ -268,9 +286,8 @@ end ---@return PeekstackConfigFieldValidator local function field_event_list() - return function(path, value, _default) - validate_event_list(path, value) - return value + return function(path, value, default) + return sanitize_event_list(path, value, default) end end diff --git a/lua/peekstack/persist/auto.lua b/lua/peekstack/persist/auto.lua index 061a049..03313df 100644 --- a/lua/peekstack/persist/auto.lua +++ b/lua/peekstack/persist/auto.lua @@ -54,8 +54,9 @@ local function resolve_root_winid() end ---@param root_winid? integer +---@param opts? { sync?: boolean } ---@return boolean -local function save_session(root_winid) +local function save_session(root_winid, opts) if not is_enabled() then return false end @@ -66,6 +67,7 @@ local function save_session(root_winid) scope = "repo", root_winid = normalize_root_winid(root_winid), silent = true, + sync = opts and opts.sync or false, }) return true end @@ -162,7 +164,7 @@ function M.save_on_leave(opts) save_timer:stop() end - return save_session(root_winid) + return save_session(root_winid, { sync = true }) end function M.setup() diff --git a/lua/peekstack/persist/init.lua b/lua/peekstack/persist/init.lua index 5eec6f9..afb5cd5 100644 --- a/lua/peekstack/persist/init.lua +++ b/lua/peekstack/persist/init.lua @@ -66,9 +66,10 @@ end ---Save the current stack to persistent storage with optional name ---@param name? string ----@param opts? { scope?: string, root_winid?: integer, silent?: boolean, on_done?: fun(success: boolean) } +---@param opts? { scope?: string, root_winid?: integer, silent?: boolean, sync?: boolean, on_done?: fun(success: boolean) } function M.save_current(name, opts) local silent = opts and opts.silent or false + local sync = opts and opts.sync or false local on_done = opts and opts.on_done or nil local function finish(success) if on_done then @@ -77,6 +78,7 @@ function M.save_current(name, opts) end if not ensure_enabled(silent) then + finish(false) return end @@ -100,42 +102,62 @@ function M.save_current(name, opts) data_items = vim.list_slice(data_items, #data_items - max_items + 1, #data_items) end - store.read(scope, { - on_done = function(read_data) - local data = migrate.ensure(read_data) - local now = os.time() + ---@param data PeekstackStoreData + ---@return PeekstackStoreData + local function upsert_session(data) + local now = os.time() + if data.sessions[resolved_name] then + data.sessions[resolved_name].items = data_items + data.sessions[resolved_name].meta.updated_at = now + else + data.sessions[resolved_name] = { + items = data_items, + meta = { + created_at = now, + updated_at = now, + }, + } + end + return data + end - if data.sessions[resolved_name] then - data.sessions[resolved_name].items = data_items - data.sessions[resolved_name].meta.updated_at = now + local function notify_save_result(success) + if not silent then + if success then + vim.notify("Session saved: " .. resolved_name, vim.log.levels.INFO) else - data.sessions[resolved_name] = { - items = data_items, - meta = { - created_at = now, - updated_at = now, - }, - } + vim.notify("Failed to save session: " .. resolved_name, vim.log.levels.WARN) end + end + if success then + user_events.emit("PeekstackSave", { + session = resolved_name, + item_count = #data_items, + }) + end + end + + if sync then + local data = upsert_session(migrate.ensure(store.read_sync(scope))) + local success = store.write_sync(scope, data) + if success then + update_cache(data) + end + notify_save_result(success) + finish(success) + return + end + + store.read(scope, { + on_done = function(read_data) + local data = upsert_session(migrate.ensure(read_data)) store.write(scope, data, { on_done = function(success) if success then update_cache(data) end - if not silent then - if success then - vim.notify("Session saved: " .. resolved_name, vim.log.levels.INFO) - else - vim.notify("Failed to save session: " .. resolved_name, vim.log.levels.WARN) - end - end - if success then - user_events.emit("PeekstackSave", { - session = resolved_name, - item_count = #data_items, - }) - end + notify_save_result(success) finish(success) end, }) @@ -156,6 +178,7 @@ function M.restore(name, opts) end if not ensure_enabled(silent) then + finish(false) return end diff --git a/lua/peekstack/persist/store.lua b/lua/peekstack/persist/store.lua index 8c1256b..deae3e9 100644 --- a/lua/peekstack/persist/store.lua +++ b/lua/peekstack/persist/store.lua @@ -7,6 +7,34 @@ local function empty_data() return { version = 2, sessions = {} } end +---@param data PeekstackStoreData +---@return string? +local function encode_data(data) + local ok, encoded = pcall(vim.json.encode, data) + if not ok then + vim.notify("Failed to encode session data", vim.log.levels.WARN) + return nil + end + return encoded +end + +---@param path string +---@return boolean +local function ensure_parent_dir(path) + local dir = vim.fs.dirname(path) + if vim.uv.fs_stat(dir) then + return true + end + + local mkdir_ok = pcall(vim.fn.mkdir, dir, "p") + if not mkdir_ok then + vim.notify("Failed to create directory: " .. dir, vim.log.levels.WARN) + return false + end + + return true +end + ---@param scope string ---@param opts { on_done: fun(data: PeekstackStoreData) } function M.read(scope, opts) @@ -94,22 +122,17 @@ function M.write(scope, data, opts) end local path = fs.scope_path(scope) - local ok, encoded = pcall(vim.json.encode, data) - if not ok then - vim.notify("Failed to encode session data", vim.log.levels.WARN) + local encoded = encode_data(data) + if not encoded then finish(false) return end - local dir = vim.fs.dirname(path) - local dir_stat = vim.uv.fs_stat(dir) - if not dir_stat then - local mkdir_ok = pcall(vim.fn.mkdir, dir, "p") - if not mkdir_ok then - vim.notify("Failed to create directory: " .. dir, vim.log.levels.WARN) - finish(false) - return - end + + if not ensure_parent_dir(path) then + finish(false) + return end + local tmp_path = path .. ".tmp" vim.uv.fs_open(tmp_path, "w", 438, function(open_err, fd) if open_err or not fd then @@ -145,4 +168,43 @@ function M.write(scope, data, opts) end) end +---@param scope string +---@param data PeekstackStoreData +---@return boolean +function M.write_sync(scope, data) + local path = fs.scope_path(scope) + local encoded = encode_data(data) + if not encoded then + return false + end + + if not ensure_parent_dir(path) then + return false + end + + local tmp_path = path .. ".tmp" + local fd = vim.uv.fs_open(tmp_path, "w", 438) + if not fd then + vim.notify("Failed to write session data: " .. path, vim.log.levels.WARN) + return false + end + + local write_ok = vim.uv.fs_write(fd, encoded, 0) + pcall(vim.uv.fs_close, fd) + if not write_ok then + vim.notify("Failed to write session data: " .. path, vim.log.levels.WARN) + pcall(vim.uv.fs_unlink, tmp_path) + return false + end + + local rename_ok = vim.uv.fs_rename(tmp_path, path) + if not rename_ok then + vim.notify("Failed to write session data: " .. path, vim.log.levels.WARN) + pcall(vim.uv.fs_unlink, tmp_path) + return false + end + + return true +end + return M diff --git a/lua/peekstack/ui/keymaps.lua b/lua/peekstack/ui/keymaps.lua index ed982f5..dc1f584 100644 --- a/lua/peekstack/ui/keymaps.lua +++ b/lua/peekstack/ui/keymaps.lua @@ -26,6 +26,12 @@ end ---@param popup table function M.apply_popup(popup) + if popup.buffer_mode == "source" then + -- Source mode uses the real editing buffer, so buffer-local mappings + -- would leak into normal editing after popup close. + return + end + local keys = config.get().ui.keys local popup_id = popup.id diff --git a/tests/config_spec.lua b/tests/config_spec.lua index cd5aca6..07ad6a6 100644 --- a/tests/config_spec.lua +++ b/tests/config_spec.lua @@ -204,10 +204,11 @@ describe("config", function() assert.equals(config.defaults.ui.inline_preview.enabled, cfg.ui.inline_preview.enabled) assert.equals(config.defaults.ui.inline_preview.max_lines, cfg.ui.inline_preview.max_lines) assert.equals(config.defaults.ui.inline_preview.hl_group, cfg.ui.inline_preview.hl_group) + assert.same(config.defaults.ui.inline_preview.close_events, cfg.ui.inline_preview.close_events) end) it("warns on invalid quick peek config", function() - config.setup({ + local cfg = config.setup({ ui = { quick_peek = { close_events = { 1, 2 }, @@ -215,6 +216,33 @@ describe("config", function() }, }) assert.is_true(has_message("ui.quick_peek.close_events")) + assert.same(config.defaults.ui.quick_peek.close_events, cfg.ui.quick_peek.close_events) + end) + + it("sanitizes mixed quick peek close_events values", function() + local cfg = config.setup({ + ui = { + quick_peek = { + close_events = { "CursorMoved", 1, "", "InsertEnter" }, + }, + }, + }) + + assert.is_true(has_message("ui.quick_peek.close_events")) + assert.same({ "CursorMoved", "InsertEnter" }, cfg.ui.quick_peek.close_events) + end) + + it("falls back when quick peek close_events is empty", function() + local cfg = config.setup({ + ui = { + quick_peek = { + close_events = {}, + }, + }, + }) + + assert.is_true(has_message("ui.quick_peek.close_events")) + assert.same(config.defaults.ui.quick_peek.close_events, cfg.ui.quick_peek.close_events) end) it("warns on invalid path config", function() diff --git a/tests/events_spec.lua b/tests/events_spec.lua index 471b72c..412c9fb 100644 --- a/tests/events_spec.lua +++ b/tests/events_spec.lua @@ -79,4 +79,29 @@ describe("peekstack.core.events", function() }) assert.equals(1, #after) end) + + it("falls back to default close_events when quick_peek.close_events is invalid", function() + config.setup({ + ui = { + quick_peek = { close_events = "CursorMoved" }, + popup = { auto_close = { enabled = false } }, + }, + }) + + assert.has_no.errors(function() + events.setup() + end) + + local buf_leave = vim.api.nvim_get_autocmds({ + group = "PeekstackEvents", + event = "BufLeave", + }) + local win_leave = vim.api.nvim_get_autocmds({ + group = "PeekstackEvents", + event = "WinLeave", + }) + + assert.equals(1, #buf_leave) + assert.equals(1, #win_leave) + end) end) diff --git a/tests/inline_preview_spec.lua b/tests/inline_preview_spec.lua index f91d8ec..d7a542e 100644 --- a/tests/inline_preview_spec.lua +++ b/tests/inline_preview_spec.lua @@ -148,4 +148,33 @@ describe("peekstack.ui.inline_preview", function() assert.equals(1, get_namespaces_calls) end) + + it("falls back to default close events when config is invalid", function() + config.setup({ + ui = { + inline_preview = { + enabled = true, + max_lines = 10, + hl_group = "PeekstackInlinePreview", + close_events = "CursorMoved", + }, + }, + }) + + assert.has_no.errors(function() + inline_preview.setup_close_events() + end) + + local buf_leave = vim.api.nvim_get_autocmds({ + group = "PeekstackInlinePreview", + event = "BufLeave", + }) + local win_leave = vim.api.nvim_get_autocmds({ + group = "PeekstackInlinePreview", + event = "WinLeave", + }) + + assert.equals(1, #buf_leave) + assert.equals(1, #win_leave) + end) end) diff --git a/tests/persist_auto_spec.lua b/tests/persist_auto_spec.lua index a82762f..7f31a35 100644 --- a/tests/persist_auto_spec.lua +++ b/tests/persist_auto_spec.lua @@ -105,4 +105,20 @@ describe("peekstack.persist.auto", function() end, 10) assert.is_true(ok, "Timed out waiting for debounced save") end) + + it("uses synchronous save on VimLeavePre path", function() + local calls = 0 + original_save = persist.save_current + persist.save_current = function(name, opts) + calls = calls + 1 + assert.equals("auto", name) + assert.equals("repo", opts.scope) + assert.is_true(opts.silent) + assert.is_true(opts.sync) + end + + local saved = auto.save_on_leave({ root_winid = vim.api.nvim_get_current_win() }) + assert.is_true(saved) + assert.equals(1, calls) + end) end) diff --git a/tests/persist_sessions_spec.lua b/tests/persist_sessions_spec.lua index ec45ce2..094929a 100644 --- a/tests/persist_sessions_spec.lua +++ b/tests/persist_sessions_spec.lua @@ -133,6 +133,22 @@ describe("peekstack.persist.sessions", function() assert.is_not_nil(sessions["test_session_2"]) end) + it("should save synchronously when sync is enabled", function() + local done = nil + persist.save_current("sync_session", { + silent = true, + sync = true, + on_done = function(success) + done = success + end, + }) + + assert.is_true(done) + + local data = migrate.ensure(read_and_wait(test_scope)) + assert.is_not_nil(data.sessions.sync_session) + end) + it("should load sessions synchronously on first list_sessions call", function() write_and_wait(test_scope, { version = 2, @@ -216,6 +232,29 @@ describe("peekstack.persist.sessions", function() -- Should not error end) + it("should invoke on_done with false when persist is disabled", function() + config.setup({ persist = { enabled = false } }) + + local save_done = nil + local restore_done = nil + + persist.save_current("disabled_save", { + silent = true, + on_done = function(success) + save_done = success + end, + }) + persist.restore("disabled_restore", { + silent = true, + on_done = function(restored) + restore_done = restored + end, + }) + + assert.is_false(save_done) + assert.is_false(restore_done) + end) + it("should migrate version 1 to version 2 schema", function() -- Write version 1 data local v1_data = { diff --git a/tests/persist_store_spec.lua b/tests/persist_store_spec.lua index 630c8af..c71b39d 100644 --- a/tests/persist_store_spec.lua +++ b/tests/persist_store_spec.lua @@ -151,4 +151,29 @@ describe("peekstack.persist.store", function() local result = store.read_sync(test_scope) assert.same({ version = 2, sessions = {} }, result) end) + + it("write_sync stores data that can be read back", function() + local data = { + version = 2, + sessions = { + sync_write = { + items = {}, + meta = { created_at = 10, updated_at = 20 }, + }, + }, + } + + local ok = store.write_sync(test_scope, data) + assert.is_true(ok) + assert.same(data, store.read_sync(test_scope)) + end) + + it("write_sync returns false when payload cannot be encoded", function() + local ok = store.write_sync(test_scope, { + version = 2, + sessions = {}, + invalid = function() end, + }) + assert.is_false(ok) + end) end) diff --git a/tests/popup_source_mode_spec.lua b/tests/popup_source_mode_spec.lua index bdb4946..dbc2810 100644 --- a/tests/popup_source_mode_spec.lua +++ b/tests/popup_source_mode_spec.lua @@ -3,6 +3,18 @@ describe("popup source mode", function() local config = require("peekstack.config") local stack = require("peekstack.core.stack") + ---@param bufnr integer + ---@param lhs string + ---@return boolean + local function has_buffer_map(bufnr, lhs) + for _, item in ipairs(vim.api.nvim_buf_get_keymap(bufnr, "n")) do + if item.lhs == lhs then + return true + end + end + return false + end + before_each(function() popup._reset() stack._reset() @@ -92,7 +104,34 @@ describe("popup source mode", function() local model = popup.create(loc, { buffer_mode = "copy" }) assert.is_not_nil(model) assert.equals("nofile", vim.bo[model.bufnr].buftype) + assert.is_true(has_buffer_map(model.bufnr, config.get().ui.keys.close)) + popup.close(model) + end) + + it("does not install popup keymaps on source buffers", function() + local temp = vim.fn.tempname() .. ".lua" + vim.fn.writefile({ "print('peekstack')" }, temp) + vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {}) + local source_bufnr = vim.api.nvim_get_current_buf() + local close_key = config.get().ui.keys.close + + assert.is_false(has_buffer_map(source_bufnr, close_key)) + + local model = popup.create({ + uri = vim.uri_from_fname(temp), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } }, + provider = "test", + }, { + buffer_mode = "source", + }) + + assert.is_not_nil(model) + assert.is_false(has_buffer_map(source_bufnr, close_key)) + popup.close(model) + assert.is_false(has_buffer_map(source_bufnr, close_key)) + + vim.fn.delete(temp) end) it("deletes copy-mode scratch buffer when render.open fails", function()