diff --git a/README.md b/README.md index b8c37f1..dec6e0a 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,8 @@ if you do not pass a name. If `persist.session.prompt_if_missing = true`, you'll for a name instead of using the default. > [!WARNING] -> Persistence target is fixed to the current git repository. +> Persistence uses repository storage when the current working directory is inside a git repository. +> Outside a git repository, sessions fall back to cwd-based storage. ### Auto persist (optional) @@ -338,6 +339,14 @@ When `persist.auto.enabled = true`, peekstack can automatically restore and save Auto persist only runs inside a git repository and always uses the repository session storage. Make sure `persist.enabled = true` as well. +## 🔁 Re-running setup + +Calling `require("peekstack").setup()` again replaces config, re-registers providers, commands, +autocmds, picker backends, and auto-persist hooks. + +It does not migrate existing popup windows, stack entries, or history in place. Updated settings apply +to future actions, and to existing stacks only after those popups are reopened, restored, or recreated. + ## 🪟 Popup buffer modes `ui.popup.buffer_mode` controls how popups are backed: diff --git a/doc/peekstack.txt b/doc/peekstack.txt index e278013..5ba13f3 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -430,7 +430,9 @@ PERSIST *peekstack-persist* Persistence is disabled by default. Enable with `persist.enabled = true`. -Persistence target is fixed to the current git repository. +Persistence uses repository storage when the current working directory is +inside a git repository. Outside a git repository, it falls back to +cwd-based storage. `persist.session.default_name` is used when a name is omitted, unless `persist.session.prompt_if_missing` is true. @@ -447,6 +449,16 @@ Auto persistence (`persist.auto`) requires `persist.enabled = true`: Auto persistence runs only inside a git repository and always uses the repository session storage. +============================================================================== +SETUP RELOAD *peekstack-setup-reload* + +Calling `require("peekstack").setup()` again replaces config and re-registers +providers, commands, autocmds, picker backends, and auto-persist hooks. + +It does not migrate existing popup windows, stack entries, or history in +place. Updated settings apply to future actions, and to existing stacks only +after those popups are reopened, restored, or recreated. + ============================================================================== BUFFER MODES *peekstack-buffer-modes* diff --git a/lua/peekstack/core/events.lua b/lua/peekstack/core/events.lua index 49fa9ed..9c4fb21 100644 --- a/lua/peekstack/core/events.lua +++ b/lua/peekstack/core/events.lua @@ -55,9 +55,9 @@ local function ensure_popup_cursor_tracking(group, bufnr) }) end ----Close all ephemeral popups across all stacks and reflow +---Close ephemeral popups that belong to the current root window. local function close_ephemeral_popups() - stack.close_ephemerals() + stack.close_ephemerals(vim.api.nvim_get_current_win()) end ---Check if a window is a floating popup diff --git a/lua/peekstack/core/stack/events.lua b/lua/peekstack/core/stack/events.lua index 1fde30b..98828e4 100644 --- a/lua/peekstack/core/stack/events.lua +++ b/lua/peekstack/core/stack/events.lua @@ -14,6 +14,47 @@ local function deps() end end +---@param stack PeekstackStackModel +---@param idx integer +---@param item PeekstackPopupModel +---@param opts? { close_window?: boolean, highlight_origin?: boolean } +local function remove_stack_popup(stack, idx, item, opts) + opts = opts or {} + if stack.zoomed_id == item.id then + stack.zoomed_id = nil + end + if opts.highlight_origin ~= false then + feedback.highlight_origin(item.origin) + end + common.emit_popup_event("PeekstackClose", item, stack.root_winid) + history.push_entry(stack, history.build_entry(item, idx)) + user_events.emit("PeekstackHistoryPush", { + popup_id = item.id, + location = item.location, + root_winid = stack.root_winid, + }) + state.unindex_popup(item) + table.remove(stack.popups, idx) + if opts.close_window ~= false and item.winid and vim.api.nvim_win_is_valid(item.winid) then + popup.close(item) + end +end + +---@param id integer +---@param item PeekstackPopupModel +---@param opts? { close_window?: boolean } +local function remove_ephemeral(id, item, opts) + opts = opts or {} + if opts.close_window ~= false and item.winid and vim.api.nvim_win_is_valid(item.winid) then + popup.close(item) + end + state.unregister_ephemeral(id) + user_events.emit( + "PeekstackClose", + user_events.build_popup_data(item, item.origin and item.origin.winid or 0, { ephemeral = true }) + ) +end + ---@param winid integer function M.handle_win_closed(winid) deps() @@ -83,21 +124,32 @@ function M.handle_buf_wipeout(bufnr) end for id, item in pairs(state.ephemerals) do if item.bufnr == bufnr then - state.unregister_ephemeral(id) + remove_ephemeral(id, item, { close_window = false }) end end for _, stack in pairs(state.stacks) do + local removed = false + local focused_removed = false for idx = #stack.popups, 1, -1 do local item = stack.popups[idx] if item.bufnr == bufnr then - if stack.zoomed_id == item.id then - stack.zoomed_id = nil + if stack.focused_id == item.id then + focused_removed = true end - state.unindex_popup(item) - table.remove(stack.popups, idx) + remove_stack_popup(stack, idx, item, { close_window = false }) + removed = true end end - layout.reflow(stack) + if removed then + if focused_removed then + if #stack.popups > 0 then + stack.focused_id = stack.popups[#stack.popups].id + else + stack.focused_id = nil + end + end + layout.reflow(stack) + end end end @@ -121,23 +173,32 @@ function M.handle_origin_wipeout(bufnr) end for id, item in pairs(state.ephemerals) do if should_close_for_origin(item) then - popup.close(item) - state.unregister_ephemeral(id) + remove_ephemeral(id, item) end end for _, stack in pairs(state.stacks) do + local removed = false + local focused_removed = false for idx = #stack.popups, 1, -1 do local item = stack.popups[idx] if should_close_for_origin(item) then - if stack.zoomed_id == item.id then - stack.zoomed_id = nil + if stack.focused_id == item.id then + focused_removed = true + end + remove_stack_popup(stack, idx, item) + removed = true + end + end + if removed then + if focused_removed then + if #stack.popups > 0 then + stack.focused_id = stack.popups[#stack.popups].id + else + stack.focused_id = nil end - popup.close(item) - state.unindex_popup(item) - table.remove(stack.popups, idx) end + layout.reflow(stack) end - layout.reflow(stack) end end diff --git a/lua/peekstack/core/stack/operations.lua b/lua/peekstack/core/stack/operations.lua index 02fa516..37b4590 100644 --- a/lua/peekstack/core/stack/operations.lua +++ b/lua/peekstack/core/stack/operations.lua @@ -432,27 +432,43 @@ function M.close_stale(now_ms, opts) end end -function M.close_ephemerals() +---@param winid? integer +function M.close_ephemerals(winid) deps() + local target_root_winid = nil + if winid ~= nil or vim.api.nvim_get_current_win() ~= nil then + target_root_winid = state.get_root_winid(winid) + end + for _, stack in pairs(state.stacks) do - local removed = false - for idx = #stack.popups, 1, -1 do - local item = stack.popups[idx] - if item.ephemeral then - popup.close(item) - state.unindex_popup(item) - table.remove(stack.popups, idx) - removed = true + if target_root_winid == nil or stack.root_winid == target_root_winid then + local removed = false + for idx = #stack.popups, 1, -1 do + local item = stack.popups[idx] + if item.ephemeral then + popup.close(item) + state.unindex_popup(item) + table.remove(stack.popups, idx) + removed = true + end + end + if removed then + layout.reflow(stack) end - end - if removed then - layout.reflow(stack) end end for id, item in pairs(state.ephemerals) do - popup.close(item) - state.unregister_ephemeral(id) + local entry = state.lookup_by_id(item.id) + local root_winid = entry and entry.root_winid or nil + if target_root_winid == nil or root_winid == target_root_winid then + popup.close(item) + state.unregister_ephemeral(id) + user_events.emit( + "PeekstackClose", + user_events.build_popup_data(item, item.origin and item.origin.winid or 0, { ephemeral = true }) + ) + end end end diff --git a/lua/peekstack/providers/grep.lua b/lua/peekstack/providers/grep.lua index 58bd503..5cb2dc0 100644 --- a/lua/peekstack/providers/grep.lua +++ b/lua/peekstack/providers/grep.lua @@ -4,6 +4,36 @@ local notify = require("peekstack.util.notify") local M = {} +---@param text string? +---@return string +local function compact_message(text) + local message = (text or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if message == "" then + return "unknown error" + end + return message +end + +---@param stderr string? +---@return boolean +local function is_ignore_file_error(stderr) + local message = compact_message(stderr):lower() + return message:find(".gitignore", 1, true) ~= nil + or message:find(".ignore", 1, true) ~= nil + or message:find(".rgignore", 1, true) ~= nil + or (message:find("ignore", 1, true) ~= nil and message:find("glob", 1, true) ~= nil) +end + +---@param stderr string? +---@return string +local function format_failure_message(stderr) + local message = compact_message(stderr) + if is_ignore_file_error(stderr) then + return "rg failed; check .gitignore/.ignore patterns or encoding: " .. message + end + return "rg failed: " .. message +end + ---@param line string ---@return string?, integer?, integer?, string? local function parse_rg_line(line) @@ -56,7 +86,7 @@ function M.search(_, cb) vim.system({ "rg", "--vimgrep", "--max-count=1000", "--", query }, { text = true }, function(result) vim.schedule(function() if result.code ~= 0 and result.code ~= 1 then - notify.warn("rg failed: " .. (result.stderr or "unknown error")) + notify.warn(format_failure_message(result.stderr)) cb({}) return end @@ -68,5 +98,6 @@ end ---Expose parser for tests. M._parse_output = parse_rg_output +M._format_failure_message = format_failure_message return M diff --git a/lua/telescope/_extensions/peekstack.lua b/lua/telescope/_extensions/peekstack.lua index d7a0bea..fc027a9 100644 --- a/lua/telescope/_extensions/peekstack.lua +++ b/lua/telescope/_extensions/peekstack.lua @@ -1,4 +1,5 @@ local ext = require("peekstack.extensions") +local notify = require("peekstack.util.notify") ---@param entry table ---@param opts? table @@ -21,7 +22,7 @@ local function open_builtin(builtin_name, provider, opts) local builtin = require("telescope.builtin") local fn = builtin[builtin_name] if not fn then - vim.notify("telescope.builtin." .. builtin_name .. " not found", vim.log.levels.WARN) + notify.warn("telescope.builtin." .. builtin_name .. " not found") return end diff --git a/tests/events_spec.lua b/tests/events_spec.lua index 94e491b..2fbbf1b 100644 --- a/tests/events_spec.lua +++ b/tests/events_spec.lua @@ -133,4 +133,41 @@ describe("peekstack.core.events", function() assert.equals(1, #buf_leave) assert.equals(2, #win_leave) end) + + it("closes quick peek popups only for the current root window", function() + local location = { + uri = vim.uri_from_bufnr(0), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 10 } }, + provider = "test", + } + + config.setup({ + ui = { + quick_peek = { close_events = { "CursorMoved" } }, + popup = { auto_close = { enabled = false } }, + }, + }) + events.setup() + + local left_win = vim.api.nvim_get_current_win() + vim.api.nvim_cmd({ cmd = "vsplit" }, {}) + local right_win = vim.api.nvim_get_current_win() + + vim.api.nvim_set_current_win(left_win) + local left_popup = stack.push(location, { stack = false }) + assert.is_not_nil(left_popup) + + vim.api.nvim_set_current_win(right_win) + local right_popup = stack.push(location, { stack = false }) + assert.is_not_nil(right_popup) + + vim.api.nvim_set_current_win(left_win) + vim.api.nvim_exec_autocmds("CursorMoved", { modeline = false }) + + assert.is_nil(stack._ephemerals()[left_popup.id]) + assert.is_not_nil(stack._ephemerals()[right_popup.id]) + + stack.close(right_popup.id) + vim.api.nvim_cmd({ cmd = "only" }, {}) + end) end) diff --git a/tests/grep_provider_spec.lua b/tests/grep_provider_spec.lua index cbcfd16..33c72d3 100644 --- a/tests/grep_provider_spec.lua +++ b/tests/grep_provider_spec.lua @@ -1,5 +1,28 @@ describe("peekstack.providers.grep", function() local grep = require("peekstack.providers.grep") + local original_notify + local original_system + local original_input + local original_executable + local notifications + + before_each(function() + original_notify = vim.notify + original_system = vim.system + original_input = vim.ui.input + original_executable = vim.fn.executable + notifications = {} + vim.notify = function(msg, level) + table.insert(notifications, { msg = msg, level = level }) + end + end) + + after_each(function() + vim.notify = original_notify + vim.system = original_system + vim.ui.input = original_input + vim.fn.executable = original_executable + end) it("parses vimgrep output with Unix paths", function() local output = "/tmp/sample.lua:3:5:hello" @@ -23,4 +46,44 @@ describe("peekstack.providers.grep", function() assert.equals("hit", items[1].text) assert.is_true(items[1].uri:find("sample.lua", 1, true) ~= nil) end) + + it("formats ignore-file failures with a targeted hint", function() + local message = grep._format_failure_message("error reading .gitignore: invalid UTF-8") + + assert.equals( + "rg failed; check .gitignore/.ignore patterns or encoding: error reading .gitignore: invalid UTF-8", + message + ) + end) + + it("warns with the ignore hint when rg reports ignore file issues", function() + local items = nil + + vim.ui.input = function(_, cb) + cb("sample") + end + vim.fn.executable = function(_) + return 1 + end + vim.system = function(_, _, cb) + cb({ + code = 2, + stdout = "", + stderr = "error reading .ignore: invalid UTF-8", + }) + end + + grep.search({}, function(result) + items = result + end) + + vim.wait(100, function() + return items ~= nil + end) + + assert.equals(0, #items) + assert.equals(1, #notifications) + assert.equals(vim.log.levels.WARN, notifications[1].level) + assert.is_true(notifications[1].msg:find("check .gitignore/.ignore patterns or encoding", 1, true) ~= nil) + end) end) diff --git a/tests/stack_spec.lua b/tests/stack_spec.lua index f6af28d..bae4a3c 100644 --- a/tests/stack_spec.lua +++ b/tests/stack_spec.lua @@ -280,11 +280,13 @@ describe("stack.handle_origin_wipeout", function() id = 1, origin = { bufnr = 10 }, origin_is_popup = true, + location = helpers.make_location(), }, { id = 2, origin = { bufnr = 10 }, origin_is_popup = false, + location = helpers.make_location(), }, } @@ -293,6 +295,67 @@ describe("stack.handle_origin_wipeout", function() assert.equals(1, #s.popups) assert.equals(1, s.popups[1].id) end) + + it("pushes history and emits events when the origin buffer is wiped", function() + local received = {} + local group = vim.api.nvim_create_augroup("PeekstackOriginWipeoutEvents", { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = { "PeekstackClose", "PeekstackHistoryPush" }, + callback = function(args) + table.insert(received, args.match) + end, + }) + + local model = stack.push(helpers.make_location()) + assert.is_not_nil(model) + + stack.handle_origin_wipeout(model.origin.bufnr) + + local history = stack.history_list() + assert.equals(1, #history) + assert.equals(model.id, history[1].popup_id) + assert.is_nil(stack.find_by_id(model.id)) + assert.same({ "PeekstackClose", "PeekstackHistoryPush" }, received) + + pcall(vim.api.nvim_del_augroup_by_id, group) + end) +end) + +describe("stack.handle_buf_wipeout", function() + before_each(function() + stack._reset() + config.setup({}) + end) + + after_each(function() + stack._reset() + end) + + it("pushes history and emits events when the popup buffer is wiped", function() + local received = {} + local group = vim.api.nvim_create_augroup("PeekstackBufWipeoutEvents", { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = { "PeekstackClose", "PeekstackHistoryPush" }, + callback = function(args) + table.insert(received, args.match) + end, + }) + + local model = stack.push(helpers.make_location()) + assert.is_not_nil(model) + + stack.handle_buf_wipeout(model.bufnr) + + local history = stack.history_list() + assert.equals(1, #history) + assert.equals(model.id, history[1].popup_id) + assert.is_nil(stack.find_by_id(model.id)) + assert.same({ "PeekstackClose", "PeekstackHistoryPush" }, received) + + pcall(vim.api.nvim_del_augroup_by_id, group) + end) end) describe("stack.close_by_id", function()