From dc50e189bb66565c756d5680edc417fdd2ca9fe0 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Mon, 5 Oct 2026 08:14:28 +0900 Subject: [PATCH 1/5] docs: describe how re-running setup reuses commands The docs said a second setup() call re-registers the user commands, but they are created once and reused, reading the config each time they run. The reload section now says which parts are rebuilt for the new config and that the commands always use the latest settings. --- README.md | 9 ++++++--- doc/peekstack.txt | 14 +++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4302b58..7e274e9 100644 --- a/README.md +++ b/README.md @@ -389,9 +389,12 @@ Auto persist only runs inside a git repository and always uses the repository se ## 🔁 Re-running setup -Calling `require("peekstack").setup()` again replaces config, re-registers providers, commands, -autocmds, picker backends, and auto-persist hooks. Providers and pickers registered with -`register_provider()` / `register_picker()` are kept. +Calling `require("peekstack").setup()` again replaces config, re-registers the built-in providers and +picker backends for the new config, and recreates the autocmds and auto-persist hooks. Providers and +pickers registered with `register_provider()` / `register_picker()` are kept. + +User commands are created by the first `setup()` call and reused afterwards; they read the config +when they run, so they always use the latest settings. 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. diff --git a/doc/peekstack.txt b/doc/peekstack.txt index c0520fb..787e6dd 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -267,7 +267,8 @@ For picker-agnostic integration: ============================================================================== COMMANDS *peekstack-cmds* -Commands are registered after `setup()` is called. +Commands are created by the first `setup()` call +(see |peekstack-setup-reload|). :PeekstackStack *:PeekstackStack* Open the stack view panel listing all active popups. @@ -511,10 +512,13 @@ paths or symbol names are sensitive. ============================================================================== SETUP RELOAD *peekstack-setup-reload* -Calling `require("peekstack").setup()` again replaces config and re-registers -providers, commands, autocmds, picker backends, and auto-persist hooks. -Providers and pickers registered with `register_provider()` / -`register_picker()` are kept. +Calling `require("peekstack").setup()` again replaces config, re-registers +the built-in providers and picker backends for the new config, and recreates +the autocmds and auto-persist hooks. Providers and pickers registered with +`register_provider()` / `register_picker()` are kept. + +User commands are created by the first `setup()` call and reused afterwards; +they read the config when they run, so they always use the latest settings. It does not migrate existing popup windows, stack entries, or history in place. Updated settings apply to future actions, and to existing stacks only From 84d2cd52d89d08c5796ca7fc65874294c9147c2f Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Mon, 5 Oct 2026 08:16:08 +0900 Subject: [PATCH 2/5] fix(persist): report which session items were skipped on restore A restore only warned with the number of skipped items, so there was no way to tell which entries failed or why, and an entry whose file had been deleted came back as an empty popup. Missing files are now skipped, the warning lists each skipped file with its reason, and the PeekstackRestore event carries the same list as `skipped`. --- lua/peekstack/persist/service.lua | 62 +++++++++++++--- lua/peekstack/types.lua | 4 ++ tests/persist_sessions_spec.lua | 115 +++++++++++++++++++++++++----- 3 files changed, 151 insertions(+), 30 deletions(-) diff --git a/lua/peekstack/persist/service.lua b/lua/peekstack/persist/service.lua index 18cee2a..c5c17cc 100644 --- a/lua/peekstack/persist/service.lua +++ b/lua/peekstack/persist/service.lua @@ -21,11 +21,15 @@ end ---@param item PeekstackSessionItem ---@param id_remap table ---@param root_winid integer stack root frozen when the restore was requested ----@return boolean restored whether a popup was actually created +---@return string? skip_reason nil when a popup was created local function restore_item(item, id_remap, root_winid) local loc = location.normalize({ uri = item.uri, range = item.range }, item.provider or "persist") if not loc then - return false + return "invalid location" + end + -- A deleted or moved file would otherwise come back as an empty popup. + if vim.startswith(loc.uri, "file://") and not vim.uv.fs_stat(vim.uri_to_fname(loc.uri)) then + return "file not found" end local parent_id = item.parent_popup_id @@ -47,7 +51,7 @@ local function restore_item(item, id_remap, root_winid) root_winid = root_winid, }) if not model then - return false + return "failed to open popup" end if item.pinned then @@ -56,7 +60,31 @@ local function restore_item(item, id_remap, root_winid) if item.popup_id then id_remap[item.popup_id] = model.id end - return true + return nil +end + +---Maximum number of skipped items listed in the restore warning. +local MAX_REPORTED_SKIPS = 5 + +---@param name string +---@param skipped PeekstackSessionSkippedItem[] +---@return string +local function format_skipped(name, skipped) + local lines = { string.format("Session restored with %d skipped item(s): %s", #skipped, name) } + for idx = 1, math.min(#skipped, MAX_REPORTED_SKIPS) do + local entry = skipped[idx] + local target = "" + if entry.uri then + -- A uri without a scheme makes vim.uri_to_fname() throw. + local ok, fname = pcall(vim.uri_to_fname, entry.uri) + target = ok and vim.fn.fnamemodify(fname, ":~:.") or entry.uri + end + table.insert(lines, string.format(" %s: %s", target, entry.reason)) + end + if #skipped > MAX_REPORTED_SKIPS then + table.insert(lines, string.format(" ... and %d more", #skipped - MAX_REPORTED_SKIPS)) + end + return table.concat(lines, "\n") end ---@param success boolean @@ -162,14 +190,27 @@ function M.restore(name, opts) ---@type table local id_remap = {} local restored_count = 0 + ---@type PeekstackSessionSkippedItem[] + local skipped = {} for _, item in ipairs(session.items) do -- Isolate each item: a single corrupt entry (bad type or a push failure) -- must not abort restoring the rest of the session. + local reason if is_valid_item(item) then - local ok, restored = pcall(restore_item, item, id_remap, root_winid) - if ok and restored then - restored_count = restored_count + 1 + local ok, result = pcall(restore_item, item, id_remap, root_winid) + if ok then + reason = result + else + reason = tostring(result) end + else + reason = "invalid entry" + end + if reason then + local uri = type(item) == "table" and type(item.uri) == "string" and item.uri or nil + table.insert(skipped, { uri = uri, reason = reason }) + else + restored_count = restored_count + 1 end end @@ -177,11 +218,9 @@ function M.restore(name, opts) stack.reflow(root_winid) end - local skipped = #session.items - restored_count - if not silent then - if skipped > 0 then - notify.warn(string.format("Session restored with %d skipped item(s): %s", skipped, resolved_name)) + if #skipped > 0 then + notify.warn(format_skipped(resolved_name, skipped)) else notify.info("Session restored: " .. resolved_name) end @@ -190,6 +229,7 @@ function M.restore(name, opts) user_events.emit("PeekstackRestore", { session = resolved_name, item_count = restored_count, + skipped = skipped, }) finish(restored_count > 0) end) diff --git a/lua/peekstack/types.lua b/lua/peekstack/types.lua index 5becf63..f641c54 100644 --- a/lua/peekstack/types.lua +++ b/lua/peekstack/types.lua @@ -32,6 +32,10 @@ ---@field buffer_mode? "copy"|"source" ---@field parent_popup_id? integer +---@class PeekstackSessionSkippedItem +---@field uri? string missing when the stored entry had no usable uri +---@field reason string why the item was not restored + ---@class PeekstackSessionMeta ---@field created_at integer ---@field updated_at integer diff --git a/tests/persist_sessions_spec.lua b/tests/persist_sessions_spec.lua index 3e8bcb0..c11b921 100644 --- a/tests/persist_sessions_spec.lua +++ b/tests/persist_sessions_spec.lua @@ -635,6 +635,78 @@ describe("peekstack.persist.sessions", function() assert.is_nil(sessions.broken) end) + it("should report each skipped item with its reason when restoring", function() + local kept = make_file("restore_kept", { "line" }) + local missing = vim.fn.tempname() .. "_restore_missing.lua" + local range = { + start = { line = 0, character = 0 }, + ["end"] = { line = 0, character = 0 }, + } + write_and_wait(test_scope, { + version = 2, + sessions = { + partial = { + items = { + { uri = vim.uri_from_fname(kept), range = range }, + { uri = vim.uri_from_fname(missing), range = range }, + { range = range }, + { uri = "broken", range = range }, + 42, + }, + meta = { created_at = 1, updated_at = 1 }, + }, + }, + }) + persist._reset_cache() + + local original_notify = vim.notify + local warnings = {} + vim.notify = function(msg, level) + if level == vim.log.levels.WARN and msg:find("skipped item(s)", 1, true) then + table.insert(warnings, msg) + end + end + local event_data = nil + local group = vim.api.nvim_create_augroup("PeekstackRestoreSkipTest", { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "PeekstackRestore", + callback = function(args) + event_data = args.data + end, + }) + + local restored = nil + persist.restore("partial", { + on_done = function(result) + restored = result + end, + }) + local waited = vim.wait(wait_timeout_ms, function() + return restored ~= nil + end, wait_interval_ms) + vim.notify = original_notify + vim.api.nvim_del_augroup_by_id(group) + assert.is_true(waited, "Timed out waiting for restore") + + assert.is_true(restored) + assert.equals(1, #stack.list()) + assert.equals(1, #warnings) + assert.truthy(warnings[1]:find("4 skipped item(s): partial", 1, true)) + assert.truthy(warnings[1]:find(vim.fn.fnamemodify(missing, ":~:.") .. ": file not found", 1, true)) + assert.truthy(warnings[1]:find(": invalid entry", 1, true)) + assert.truthy(warnings[1]:find("broken: failed to open popup", 1, true)) + + assert.is_not_nil(event_data) + assert.equals(1, event_data.item_count) + assert.same({ + { uri = vim.uri_from_fname(missing), reason = "file not found" }, + { reason = "invalid entry" }, + { uri = "broken", reason = "failed to open popup" }, + { reason = "invalid entry" }, + }, event_data.skipped) + end) + it("should rename a session", function() push_popup("rename_session", { title = "Rename me" }) persist.save_current("old_name", { silent = true, sync = true }) @@ -725,13 +797,13 @@ describe("peekstack.persist.sessions", function() batched = { items = { { - uri = "file:///tmp/a.lua", + uri = vim.uri_from_fname(make_file("a")), range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, provider = "test", ts = os.time(), }, { - uri = "file:///tmp/b.lua", + uri = vim.uri_from_fname(make_file("b")), range = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 1 } }, provider = "test", ts = os.time(), @@ -793,7 +865,7 @@ describe("peekstack.persist.sessions", function() optional_fields = { items = { { - uri = "file:///tmp/optional.lua", + uri = vim.uri_from_fname(make_file("optional")), range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, ts = os.time(), }, @@ -1041,7 +1113,7 @@ describe("peekstack.persist.sessions", function() restore_fields = { items = { { - uri = "file:///tmp/a.lua", + uri = vim.uri_from_fname(make_file("a")), range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, provider = "test", ts = os.time(), @@ -1104,14 +1176,14 @@ describe("peekstack.persist.sessions", function() remap_parent = { items = { { - uri = "file:///tmp/parent.lua", + uri = vim.uri_from_fname(make_file("parent")), range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, provider = "test", ts = os.time(), popup_id = 100, }, { - uri = "file:///tmp/child.lua", + uri = vim.uri_from_fname(make_file("child")), range = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 1 } }, provider = "test", ts = os.time(), @@ -1176,7 +1248,7 @@ describe("peekstack.persist.sessions", function() orphan_parent = { items = { { - uri = "file:///tmp/orphan.lua", + uri = vim.uri_from_fname(make_file("orphan")), range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, provider = "test", ts = os.time(), @@ -1228,6 +1300,8 @@ describe("peekstack.persist.sessions", function() it("should skip corrupt items and still restore valid ones", function() local original_push = stack.push local original_reflow = stack.reflow + local valid_a = vim.uri_from_fname(make_file("valid_a")) + local valid_b = vim.uri_from_fname(make_file("valid_b")) write_and_wait(test_scope, { version = 2, @@ -1235,7 +1309,7 @@ describe("peekstack.persist.sessions", function() corrupt_items = { items = { { - uri = "file:///tmp/valid_a.lua", + uri = valid_a, range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, provider = "test", ts = os.time(), @@ -1248,13 +1322,13 @@ describe("peekstack.persist.sessions", function() }, -- Non-table range: fails the coarse type check. { - uri = "file:///tmp/corrupt.lua", + uri = vim.uri_from_fname(make_file("corrupt")), range = 42, provider = "test", ts = os.time(), }, { - uri = "file:///tmp/valid_b.lua", + uri = valid_b, range = { start = { line = 2, character = 0 }, ["end"] = { line = 2, character = 1 } }, provider = "test", ts = os.time(), @@ -1292,8 +1366,8 @@ describe("peekstack.persist.sessions", function() assert.is_true(restored) assert.equals(2, #pushed) - assert.equals("file:///tmp/valid_a.lua", pushed[1]) - assert.equals("file:///tmp/valid_b.lua", pushed[2]) + assert.equals(valid_a, pushed[1]) + assert.equals(valid_b, pushed[2]) assert.equals(1, reflow_calls) end) @@ -1308,6 +1382,9 @@ describe("peekstack.persist.sessions", function() it("should isolate a push failure to a single item when restoring", function() local original_push = stack.push local original_reflow = stack.reflow + local ok1 = vim.uri_from_fname(make_file("ok1")) + local boom = vim.uri_from_fname(make_file("boom")) + local ok2 = vim.uri_from_fname(make_file("ok2")) write_and_wait(test_scope, { version = 2, @@ -1315,19 +1392,19 @@ describe("peekstack.persist.sessions", function() push_failure = { items = { { - uri = "file:///tmp/ok1.lua", + uri = ok1, range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, provider = "test", ts = os.time(), }, { - uri = "file:///tmp/boom.lua", + uri = boom, range = { start = { line = 1, character = 0 }, ["end"] = { line = 1, character = 1 } }, provider = "test", ts = os.time(), }, { - uri = "file:///tmp/ok2.lua", + uri = ok2, range = { start = { line = 2, character = 0 }, ["end"] = { line = 2, character = 1 } }, provider = "test", ts = os.time(), @@ -1343,7 +1420,7 @@ describe("peekstack.persist.sessions", function() local ok, err = pcall(function() stack.push = function(loc, _opts) - if loc.uri == "file:///tmp/boom.lua" then + if loc.uri == boom then error("simulated push failure") end table.insert(pushed, loc.uri) @@ -1369,8 +1446,8 @@ describe("peekstack.persist.sessions", function() -- The failing item is isolated; the surrounding valid items still restore. assert.is_true(restored) assert.equals(2, #pushed) - assert.equals("file:///tmp/ok1.lua", pushed[1]) - assert.equals("file:///tmp/ok2.lua", pushed[2]) + assert.equals(ok1, pushed[1]) + assert.equals(ok2, pushed[2]) assert.equals(1, reflow_calls) end) @@ -1392,7 +1469,7 @@ describe("peekstack.persist.sessions", function() all_fail = { items = { { - uri = "file:///tmp/unrestorable.lua", + uri = vim.uri_from_fname(make_file("unrestorable")), range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 1 } }, provider = "test", ts = os.time(), From 9d94eb36e850a175abf4fdb5cdcdb188013c0022 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Mon, 5 Oct 2026 08:16:42 +0900 Subject: [PATCH 3/5] docs(persist): describe what a session stores and how edge cases behave The persist docs did not say which stack a session holds, that restoring adds to the current stack, or that storage is kept per repository or worktree as seen from the window's cwd. They now also cover saving an empty stack, the synchronous save on leave, unreadable storage, and the list of skipped items reported on restore. --- README.md | 30 ++++++++++++++++++++------ doc/peekstack.txt | 54 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7e274e9..44acd5a 100644 --- a/README.md +++ b/README.md @@ -352,15 +352,28 @@ When `persist.enabled = true`, `PeekstackSaveSession` uses `persist.session.defa if you do not pass a name. If `persist.session.prompt_if_missing = true`, you'll be prompted for a name instead of using the default. +A session holds one stack: the popups of the current window (or of the window the stack view +belongs to), oldest first, keeping the newest `persist.max_items`. Only locations, titles, pins, +buffer modes and parent links are saved; edits made in copy-mode popups are not. +Restoring a session adds its popups to the current window's stack and keeps the popups already there. + > [!WARNING] > 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. > The storage is resolved when a save, delete, or rename is requested, so changing directory while > it is still being written does not move it to another repository. -If the storage file cannot be read (an I/O error, invalid JSON, or a version this release does not -know), peekstack warns and refuses to save, delete, or rename sessions in it instead of replacing -it with an empty store. Fix or move the file to recover. +Each git repository (each worktree, found by looking upward from the current window's working +directory, so `:lcd` / `:tcd` count) has its own storage file, and session names are scoped to it. + +- **Empty stacks**: saving an empty stack replaces the session with an empty one; restoring an empty + session reports that there is no saved session. Use `:PeekstackDeleteSession` to remove one. +- **Skipped items**: entries whose file no longer exists or that are malformed are skipped on restore. + The warning lists the first five skipped files with their reasons, and the `PeekstackRestore` event + data carries every skipped item as `skipped`. +- **Unreadable storage**: if the storage file cannot be read (an I/O error, invalid JSON, or a version + this release does not know), peekstack warns and refuses to save, delete, or rename sessions in it + instead of replacing it with an empty store. Fix or move the file to recover. > [!IMPORTANT] > Sessions are written as plain JSON under `vim.fn.stdpath("state") .. "/peekstack/"`. Each entry @@ -379,10 +392,13 @@ When `persist.auto.enabled = true`, peekstack can automatically restore and save - **Save on leave** on `VimLeavePre` if `save_on_leave = true` The auto session holds a single stack: the one that changed most recently, saved to the repository -that was current when it changed. Leaving Neovim saves that stack even if the cursor is in another -window, skips the save if no stack changed during the session, and keeps the stored session as-is -once that stack's window has been closed. The save on leave waits up to one second for earlier -saves to finish; if they are still writing, it is skipped with a warning rather than racing them. +that was current when it changed. Closing every popup of that stack saves it empty, so nothing is +restored next time. + +Leaving Neovim saves that stack synchronously, even if the cursor is in another window. It skips the +save if no stack changed during the session, and keeps the stored session as-is once that stack's +window has been closed. The save on leave waits up to one second for earlier saves to finish; if they +are still writing, it is skipped with a warning rather than racing them. Auto persist only runs inside a git repository and always uses the repository session storage. Make sure `persist.enabled = true` as well. diff --git a/doc/peekstack.txt b/doc/peekstack.txt index 787e6dd..ebe5d0d 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -464,16 +464,38 @@ PERSIST *peekstack-persist* Persistence is disabled by default. Enable with `persist.enabled = true`. +A session holds one stack: the popups of the current window (or of the +window the stack view belongs to), oldest first, keeping the newest +`persist.max_items`. Only locations, titles, pins, buffer modes and parent +links are saved; edits made in copy-mode popups are not. Restoring a +session adds its popups to the current window's stack and keeps the popups +already there. + 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. The storage is resolved when a save, delete, or rename -is requested, so changing directory while it is still being written does -not move it to another repository. - -If the storage file cannot be read (an I/O error, invalid JSON, or a -version this release does not know), peekstack warns and refuses to save, -delete, or rename sessions in it instead of replacing it with an empty -store. Fix or move the file to recover. +cwd-based storage. Each git repository (each worktree, found by looking +upward from the current window's working directory, so |:lcd| and |:tcd| +count) has its own storage file, and session names are scoped to it. The +storage is resolved when a save, delete, or rename is requested, so +changing directory while it is still being written does not move it to +another repository. + +Empty stacks: + Saving an empty stack replaces the session with an empty one; restoring + an empty session reports that there is no saved session. Use + |:PeekstackDeleteSession| to remove one. + +Skipped items: + Entries whose file no longer exists or that are malformed are skipped + on restore. The warning lists the first five skipped files with their + reasons, and the `PeekstackRestore` event data carries every skipped + item as `skipped` (a list of `{ uri?, reason }`). + +Unreadable storage: + If the storage file cannot be read (an I/O error, invalid JSON, or a + version this release does not know), peekstack warns and refuses to + save, delete, or rename sessions in it instead of replacing it with an + empty store. Fix or move the file to recover. `persist.session.default_name` is used when a name is omitted, unless `persist.session.prompt_if_missing` is true. @@ -489,11 +511,15 @@ Auto persistence (`persist.auto`) requires `persist.enabled = true`: The auto session holds a single stack: the one that changed most recently, saved to the repository that was current when it changed. -Leaving Neovim saves that stack even if the cursor is in another window, -skips the save if no stack changed during the session, and keeps the -stored session as-is once that stack's window has been closed. The save on -leave waits up to one second for earlier saves to finish; if they are -still writing, it is skipped with a warning rather than racing them. +Closing every popup of that stack saves it empty, so nothing is restored +next time. + +Leaving Neovim saves that stack synchronously, even if the cursor is in +another window. It skips the save if no stack changed during the session, +and keeps the stored session as-is once that stack's window has been +closed. The save on leave waits up to one second for earlier saves to +finish; if they are still writing, it is skipped with a warning rather +than racing them. Auto persistence runs only inside a git repository and always uses the repository session storage. @@ -570,7 +596,7 @@ Persist events: Event data keys (vary per event): event, popup_id, winid, bufnr, root_winid, provider, location - session, item_count, from, to, action, ephemeral + session, item_count, skipped, from, to, action, ephemeral ============================================================================== KEYMAPS *peekstack-keymaps* From 87f626d4afec106f7d06ede78b03355ad66585cc Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Mon, 5 Oct 2026 08:17:14 +0900 Subject: [PATCH 4/5] docs: explain stack view focus and zoom and popup edit behavior It was unclear that the stack view closes whenever focus leaves it, so on an entry also closes the panel, and that z zooms the top popup rather than the selected one. The panel is now described as read-only, and the buffer mode docs say what happens to edits in copy and source popups on save, close, history, sessions and promote. --- README.md | 17 +++++++++++++++-- doc/peekstack.txt | 23 +++++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 44acd5a..fd571e5 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Defaults inside popup windows: Defaults in stack view: -- `` — focus selected popup +- `` — focus selected popup (moves to it, which closes the panel) - `dd` — close selected popup - `u` — undo close (restore last) - `U` — restore all closed popups @@ -146,10 +146,14 @@ Defaults in stack view: - `/` — filter - `gg/G` — jump to first/last stack item - `j/k` — move cursor by stack item (skip header/preview lines) -- `z` — toggle zoom (maximize top popup) +- `z` — toggle zoom of the top popup (the most recently pushed one, not the selected entry) - `?` — help - `q` — close +The stack view is a read-only panel that closes as soon as focus leaves it. Motions, visual selection +and yanking work as usual; editing commands fail because the buffer is not modifiable, and `/` filters +the list instead of searching. + ## ⚙️ Configuration Configure via `require("peekstack").setup({ ... })`. @@ -423,6 +427,15 @@ to future actions, and to existing stacks only after those popups are reopened, - **source**: uses the real source buffer; useful for editing, with safety options in `ui.popup.source` (`confirm_on_close`, `prevent_auto_close_if_modified`) +A copy popup is a snapshot taken when it opens (up to 500 lines around the target in large files) +and does not follow later changes to the file. Edits made in it with `ui.popup.editable = true` +stay in the popup: they are never written to the file and are dropped when it closes, so history, +sessions and promote reopen the file itself. + +A source popup edits the real buffer, so `:w` saves the file. Closing the popup keeps the buffer +loaded with any unsaved changes; `confirm_on_close` asks before the close key closes a modified +popup, and `prevent_auto_close_if_modified` keeps auto close away from it. + Switching buffers inside a popup (`:buffer`, `:edit`) keeps the popup and makes it follow the new buffer as a source popup at the cursor, so its title, keymaps, provider requests and history refer to that buffer. diff --git a/doc/peekstack.txt b/doc/peekstack.txt index ebe5d0d..bbbb569 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -562,6 +562,17 @@ BUFFER MODES *peekstack-buffer-modes* `ui.popup.source.confirm_on_close` prompts on close if modified `ui.popup.source.prevent_auto_close_if_modified` blocks auto-close +A copy popup is a snapshot taken when it opens (up to 500 lines around the +target in large files) and does not follow later changes to the file. +Edits made in it with `ui.popup.editable = true` stay in the popup: they are +never written to the file and are dropped when it closes, so history, +sessions and promote reopen the file itself. + +A source popup edits the real buffer, so `:w` saves the file. Closing the +popup keeps the buffer loaded with any unsaved changes; `confirm_on_close` +asks before the close key (`ui.keys.close`) closes a modified popup, and +`prevent_auto_close_if_modified` keeps auto close away from it. + Switching buffers inside a popup (`:buffer`, `:edit`) keeps the popup and makes it follow the new buffer as a "source" popup at the cursor: its title, keymaps, provider requests and history entry refer to that buffer. @@ -575,6 +586,14 @@ STACK VIEW *peekstack-stack-view* "left" left-side panel "bottom" bottom panel across full editor width +The panel is read-only and closes as soon as focus leaves it, so ``, +which moves to the selected popup, also closes it. Motions, visual +selection and yanking work as usual; editing commands fail because the +buffer is not modifiable, and `/` filters the list instead of searching. + +`z` toggles zoom of the top popup (the most recently pushed one), not of +the selected entry, the same as |:PeekstackZoom|. + ============================================================================== EVENTS *peekstack-events* @@ -615,7 +634,7 @@ Default keymaps inside popup windows: Stack view keymaps: - `` Focus the selected popup + `` Focus the selected popup (closes the panel) `dd` Close the selected popup `u` Undo close (restore last closed popup) `U` Restore all closed popups @@ -625,7 +644,7 @@ Stack view keymaps: `/` Filter the list `gg/G` Jump to first/last stack item `j/k` Move cursor by stack item (skip header/preview lines) - `z` Toggle zoom (maximize top popup) + `z` Toggle zoom of the top popup (not the selected entry) `?` Toggle help `q` Close the stack view From 61efe4aba592e526d341d410e40855bbda901e17 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Mon, 5 Oct 2026 08:17:43 +0900 Subject: [PATCH 5/5] docs(marks): explain when numbered and special marks are listed The marks options did not say that the default include only covers letters, that numbered marks must be added to include, or that special marks need both include_special and their character in include. The docs now spell this out with an example, and a test locks down that include_special alone does not add special marks. --- README.md | 5 +++++ doc/peekstack.txt | 18 +++++++++++++++++- tests/marks_provider_spec.lua | 22 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fd571e5..edba2d6 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,11 @@ Built-in provider names: `diagnostics.under_cursor`, `diagnostics.in_buffer`, `file.under_cursor`, `grep.search`, `marks.buffer`, `marks.global`, `marks.all` (marks require their provider enabled; `grep.search` requires `rg`). +The marks providers show the characters in `providers.marks.include`, letters only by default. +Numbered marks (`0`-`9`) appear only after adding them to `include`. Special marks +(`` ' ` ^ . < > [ ] " ``) need both `include_special = true` and their character in `include`; +`include_special` alone adds nothing. + ## 💻 Commands - `:PeekstackStack` — open the stack view panel diff --git a/doc/peekstack.txt b/doc/peekstack.txt index bbbb569..69e94fc 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -356,9 +356,25 @@ Marks (requires providers.marks.enable = true): marks.buffer buffer-local marks marks.global global marks marks.all buffer + global marks - Filter with providers.marks.include / include_special. Scope is selected by the provider name. + `providers.marks.include` lists the mark characters to show; the + default is the letters `a`-`z` and `A`-`Z`. Numbered marks (`0`-`9`) + are shown only after adding them to `include`. Special marks + (`'` `` ` `` `^` `.` `<` `>` `[` `]` `"`) need both + `include_special = true` and their character in `include`; + `include_special` alone adds nothing. +>lua + providers = { + marks = { + enable = true, + include = "abcdefghijklmnopqrstuvwxyz" + .. "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.^", + include_special = true, + }, + } +< + ============================================================================== API *peekstack-api* diff --git a/tests/marks_provider_spec.lua b/tests/marks_provider_spec.lua index 1b74f86..8d59652 100644 --- a/tests/marks_provider_spec.lua +++ b/tests/marks_provider_spec.lua @@ -127,6 +127,28 @@ describe("marks provider", function() vim.api.nvim_buf_delete(bufnr, { force = true }) end) + it("adds special marks only when they are also listed in include", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "line1", "line2", "line3" }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_buf_set_mark(bufnr, "<", 2, 0, {}) + + local function has_mark(include) + local locations = marks_util.collect("buffer", bufnr, { include = include, include_special = true }) + for _, loc in ipairs(locations) do + if loc.text and loc.text:find("[<]", 1, true) then + return true + end + end + return false + end + + assert.is_false(has_mark("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")) + assert.is_true(has_mark("<")) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + it("returns locations with valid structure", function() local bufnr = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "hello world", "second line" })