From 1b04fd7d5d274898dd3b611cbab3c055d6749f5b Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 04:06:59 -0700 Subject: [PATCH 1/9] fix(docs): correct README typos and inaccurate examples - 'it's tmux window name' -> 'its tmux window name' (possessive) - FAQ snippet referenced provider = 'tmux_window' which does not exist; corrected to 'tmux_window_name' - Providers table claimed tmux-pane-%17/ but util.sanitize strips '%'; corrected to tmux-pane-_17/ with a clarifying note --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d4b8908..55cb5c6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ review. They all collide on the same session file and clobber each other. `persistence-scope.nvim` fixes that by adding a **scope** to the session path. By default it uses the current tmux window name; each Neovim instance gets its -own isolated session scoped to it's tmux window name for automatic persistence +own isolated session scoped to its tmux window name for automatic persistence and restoration. ```text @@ -193,7 +193,7 @@ no extra scope directory. | --- | --- | --- | | `"tmux_window_name"` *(default)* | `#{window_name}` | `tmux-api/` | | `"tmux_window_index"` | `#{window_index}` | `tmux-window-2/` | -| `"tmux_pane_id"` | `#{pane_id}` | `tmux-pane-%17/` | +| `"tmux_pane_id"` | `#{pane_id}` | `tmux-pane-_17/` *(`%` is sanitized to `_`)* | | `"tmux_pane_index"` | `#{pane_index}` | `tmux-pane-0/` | | `"tmux_session_window"` | `#{session_name}:#{window_name}` | `tmux-work_api/` | @@ -304,7 +304,7 @@ instead: { "avgvstvs96/persistence-scope.nvim", dependencies = { "folke/persistence.nvim" }, - opts = { branch = true, need = 1, provider = "tmux_window" }, + opts = { branch = true, need = 1, provider = "tmux_window_name" }, } ``` From bba03748d647031fb6335db00147c9a74f554163 Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 04:07:17 -0700 Subject: [PATCH 2/9] refactor: split init.lua into focused modules init.lua was doing six jobs at once (config, scope state, file parsing, item construction, Snacks preview rendering, restore decision tree). Reduce it to ~110 lines of public API plus setup() wiring, with each concern in its own module: config.lua defaults + merge, typed PersistenceScope.Config scope.lua provider resolution, current scope, base_dir/session_dir, scope-from-file session.lua glob, .vim parsing, item construction, load_file, recent_count, typed PersistenceScope.SessionItem restore.lua the restore() decision tree, isolated and testable Snacks-specific preview rendering (SnacksPicker* highlight groups) moved out of init.lua and into pickers/snacks.lua where it belongs. Preview building is now lazy so the vim.ui fallback never pays for it. All inner modules are lazy-required from init.lua so importing the package does almost no work until a function is actually called. Bundled in this commit because they touch the same setup() function: - Friendly vim.notify error when folke/persistence.nvim is missing, instead of a cryptic Lua stack trace. - Restore-failure notify now prefixed with 'persistence-scope:' so users can tell which plugin is talking. This is the one intentional behavioral change; verified against the pre-refactor code that everything else is bit-for-bit equivalent. - Added desc fields to the user-command registrations. Public API surface is unchanged: setup, restore, select, sessions, load_file, config, scope. --- lua/persistence_scope/config.lua | 56 ++++ lua/persistence_scope/init.lua | 334 +++++------------------ lua/persistence_scope/pickers/snacks.lua | 136 ++++++++- lua/persistence_scope/restore.lua | 55 ++++ lua/persistence_scope/scope.lua | 54 ++++ lua/persistence_scope/session.lua | 123 +++++++++ 6 files changed, 479 insertions(+), 279 deletions(-) create mode 100644 lua/persistence_scope/config.lua create mode 100644 lua/persistence_scope/restore.lua create mode 100644 lua/persistence_scope/scope.lua create mode 100644 lua/persistence_scope/session.lua diff --git a/lua/persistence_scope/config.lua b/lua/persistence_scope/config.lua new file mode 100644 index 0000000..5e4c7e3 --- /dev/null +++ b/lua/persistence_scope/config.lua @@ -0,0 +1,56 @@ +local M = {} + +---@class PersistenceScope.Config +---@field provider string|fun():PersistenceScope.Scope? +---@field picker "auto"|"snacks"|"vim_ui" +---@field base_dir string +---@field branch boolean +---@field need integer? +---@field recent_seconds integer +---@field snacks table + +---@type PersistenceScope.Config +M.defaults = { + -- Scope used to choose the session directory. Either the name of a + -- built-in provider (see |persistence-scope-providers|) or a function + -- returning a scope table. + provider = "tmux_window_name", + + -- Picker used by `select()` and ambiguous restores. + -- "auto" → Snacks when available, otherwise vim.ui.select + -- "snacks" → force Snacks + -- "vim_ui" → force vim.ui.select + picker = "auto", + + -- Base directory for all session files. The scope's directory is appended. + base_dir = vim.fn.stdpath("state") .. "/sessions/", + + -- Forwarded to persistence.nvim. When true, non-main branches get + -- their own session files. + branch = true, + + -- Forwarded to persistence.nvim. Minimum number of file buffers required + -- to autosave a session. + need = nil, + + -- If more than one current-scope session was modified within this window + -- of time, restore opens the picker instead of guessing. + recent_seconds = 4 * 60 * 60, + + -- Extra options forwarded to the Snacks picker. + snacks = {}, +} + +---@type PersistenceScope.Config +M.options = vim.deepcopy(M.defaults) + +local util = require("persistence_scope.util") + +---@param opts table? +function M.setup(opts) + M.options = vim.tbl_deep_extend("force", vim.deepcopy(M.defaults), opts or {}) + M.options.base_dir = util.with_slash(M.options.base_dir) + return M.options +end + +return M diff --git a/lua/persistence_scope/init.lua b/lua/persistence_scope/init.lua index 146d590..dafae63 100644 --- a/lua/persistence_scope/init.lua +++ b/lua/persistence_scope/init.lua @@ -1,309 +1,115 @@ -local providers = require("persistence_scope.providers") -local pickers = require("persistence_scope.pickers") -local util = require("persistence_scope.util") +-- persistence-scope.nvim +-- Scoped sessions for folke/persistence.nvim. +-- +-- This module is the public API. The actual work lives in: +-- * persistence_scope.config — defaults / merged options +-- * persistence_scope.scope — current scope resolution +-- * persistence_scope.session — session enumeration + load_file +-- * persistence_scope.restore — restore() decision tree +-- * persistence_scope.pickers — Snacks / vim.ui.select dispatch +-- * persistence_scope.health — :checkhealth +-- +-- All of these are lazy-required to keep startup cost minimal. local M = {} -M.config = { - provider = "tmux_window_name", - picker = "auto", - base_dir = vim.fn.stdpath("state") .. "/sessions/", - branch = true, - recent_seconds = 4 * 60 * 60, - snacks = {}, -} +---@type PersistenceScope.Config +M.config = require("persistence_scope.config").defaults +---@type PersistenceScope.Scope? M.scope = nil -local function base_dir() - return util.with_slash(M.config.base_dir) -end - -local function session_dir() - if M.scope and M.scope.dir then - return util.join(base_dir(), M.scope.dir) .. "/" - end - return base_dir() -end - -local function scope_from_file(file) - local parent = vim.fn.fnamemodify(file, ":h") - if util.normalize(parent) == util.normalize(base_dir()) then - return nil, "global" - end - - local dir = vim.fn.fnamemodify(parent, ":t") - local label = dir:gsub("^tmux%-", "") - return dir, label -end - -local function home_alias(path) - if not path or path == "" then - return "unknown" - end - return vim.fn.fnamemodify(path, ":~") -end - -local function cwd_relative(path, cwd) - local normalized_path = util.normalize(path) - local normalized_cwd = util.normalize(cwd) - if not normalized_path then - return path or "unknown" - end - if not normalized_cwd then - return home_alias(normalized_path) - end - if normalized_path == normalized_cwd then - return "." - end - - local prefix = normalized_cwd .. "/" - if normalized_path:sub(1, #prefix) == prefix then - return normalized_path:sub(#prefix + 1) - end - return home_alias(normalized_path) -end - -local function preview_line(lines, extmarks, text, marks) - lines[#lines + 1] = text - local row = #lines - for _, mark in ipairs(marks or {}) do - extmarks[#extmarks + 1] = { - row = row, - col = mark.col, - end_col = mark.end_col, - hl_group = mark.hl_group, - hl_mode = "replace", - priority = 200, - } - end -end - -local function preview_field(lines, extmarks, label, value, hl_group) - value = tostring(value or "none") - local text = ("%-8s %s"):format(label, value) - preview_line(lines, extmarks, text, { - { col = 0, end_col = #label, hl_group = "SnacksPickerDimmed" }, - { col = 9, end_col = #text, hl_group = hl_group }, - }) -end - -local function preview_data(item) - local lines = {} - local extmarks = {} - - preview_line(lines, extmarks, "# Session", { - { col = 0, end_col = 9, hl_group = "@markup.heading.1.markdown" }, - }) - preview_field(lines, extmarks, "Path", home_alias(item.file), "SnacksPickerComment") - preview_field(lines, extmarks, "CWD", home_alias(item.cwd), "SnacksPickerDirectory") - preview_field(lines, extmarks, "Scope", item.scope_label or "global", "SnacksPickerSpecial") - preview_field(lines, extmarks, "Branch", item.branch or "none", item.branch and "SnacksPickerGitBranch" or "SnacksPickerComment") - preview_field(lines, extmarks, "Modified", os.date("%Y-%m-%d %H:%M:%S", item.mtime), "SnacksPickerTime") - preview_line(lines, extmarks, "") - local files_title = ("## Files (%d)"):format(#item.buffers) - preview_line(lines, extmarks, files_title, { - { col = 0, end_col = #files_title, hl_group = "@markup.heading.2.markdown" }, - }) - - if #item.buffers == 0 then - preview_line(lines, extmarks, " none parsed", { - { col = 2, end_col = 13, hl_group = "SnacksPickerComment" }, - }) - else - for _, file in ipairs(item.buffers) do - local path = cwd_relative(file, item.cwd) - local text = "- " .. path - preview_line(lines, extmarks, text, { - { col = 2, end_col = #text, hl_group = "SnacksPickerFile" }, - }) - end - end - - return { - text = table.concat(lines, "\n"), - ft = "markdown", - extmarks = extmarks, - loc = false, - } -end - -local function item_from_file(file) - local mtime = util.stat(file) - if not mtime then - return nil - end - - local name_cwd, branch = util.decode_session_name(file) - local file_cwd, buffers = util.parse_session_file(file) - local scope_dir, scope_label = scope_from_file(file) - local cwd = file_cwd or name_cwd - if not cwd then - return nil - end - - local item = { - file = file, - session = file, - cwd = cwd, - branch = branch, - scope_dir = scope_dir, - scope_label = scope_label, - mtime = mtime, - age = util.reltime(mtime), - buffers = buffers, - buffer_summary = util.buffer_summary(buffers), - } - - item.text = table.concat({ - item.scope_label or "global", - item.cwd, - item.branch or "", - item.buffer_summary, - table.concat(item.buffers, " "), - }, " ") - item.preview = preview_data(item) - - return item -end +-- Public API +---List session items matching the given filter. +---@param opts? { cwd?: string, scope_dir?: string, items?: PersistenceScope.SessionItem[] } +---@return PersistenceScope.SessionItem[] function M.sessions(opts) - opts = opts or {} - if opts.items then - return opts.items - end - - local wanted_cwd = util.normalize(opts.cwd) - local wanted_scope = opts.scope_dir - local items = {} - - for _, file in ipairs(util.glob_sessions(base_dir())) do - local item = item_from_file(file) - if - item - and (not wanted_cwd or item.cwd == wanted_cwd) - and (not wanted_scope or item.scope_dir == wanted_scope) - then - items[#items + 1] = item - end - end - - table.sort(items, function(a, b) - if a.mtime == b.mtime then - return a.file < b.file - end - return a.mtime > b.mtime - end) - return items + return require("persistence_scope.session").list(opts) end +---Source a specific session file, firing persistence's LoadPre/LoadPost. +---@param file string +---@return boolean function M.load_file(file) - if not file or vim.fn.filereadable(file) == 0 then - return false - end - - local persistence = require("persistence") - persistence.fire("LoadPre") - vim.cmd("silent! source " .. vim.fn.fnameescape(file)) - persistence.fire("LoadPost") - return true + return require("persistence_scope.session").load_file(file) end +---Open the session picker. Always opens — use `restore()` for the smart path. +---@param opts? { items?: PersistenceScope.SessionItem[], title?: string, picker?: string, snacks?: table } function M.select(opts) + local config = require("persistence_scope.config").options opts = vim.tbl_deep_extend("force", { - picker = M.config.picker, - snacks = M.config.snacks, + picker = config.picker, + snacks = config.snacks, title = "Sessions", }, opts or {}) local items = M.sessions(opts) if #items == 0 then - vim.notify("No saved sessions found", vim.log.levels.INFO) + vim.notify("persistence-scope: no saved sessions found", vim.log.levels.INFO) return end - return pickers.select(items, opts, function(item) + return require("persistence_scope.pickers").select(items, opts, function(item) M.load_file(item.file) end) end -local function recent_count(items) - local now = os.time() - local count = 0 - for _, item in ipairs(items) do - if now - item.mtime <= M.config.recent_seconds then - count = count + 1 - end - end - return count -end - +---Smart restore. See |persistence-scope-restore|. +---@return boolean function M.restore() - local cwd = util.normalize(vim.fn.getcwd()) - local candidates = M.sessions({ cwd = cwd }) - - if M.scope and M.scope.dir then - local same_scope = vim.tbl_filter(function(item) - return item.scope_dir == M.scope.dir - end, candidates) + return require("persistence_scope.restore").run(M.select) +end - if #same_scope > 0 then - if recent_count(same_scope) > 1 then - return M.select({ - items = same_scope, - title = ("Recent sessions for %s"):format(M.scope.label), - }) - end - return M.load_file(util.newest(same_scope).file) - end +-- Setup - if #candidates > 0 then - return M.select({ - items = candidates, - title = "Sessions for this directory", - }) - end - elseif #candidates == 1 then - return M.load_file(candidates[1].file) - elseif #candidates > 1 then - if recent_count(candidates) > 1 then - return M.select({ - items = candidates, - title = "Sessions for this directory", - }) - end - return M.load_file(util.newest(candidates).file) +local function ensure_persistence() + local ok, persistence = pcall(require, "persistence") + if not ok then + vim.notify( + "persistence-scope.nvim: folke/persistence.nvim is not installed. " + .. "Add it as a dependency to enable session persistence.", + vim.log.levels.ERROR + ) + return nil end - - vim.notify("No saved session found for this scope or cwd", vim.log.levels.INFO) - return false + return persistence end local function create_commands() vim.api.nvim_create_user_command("PersistenceScopeRestore", function() M.restore() - end, { force = true }) + end, { desc = "Restore the best matching session for cwd + scope", force = true }) + vim.api.nvim_create_user_command("PersistenceScopeSelect", function() M.select() - end, { force = true }) + end, { desc = "Open the persistence-scope session picker", force = true }) end +---Configure persistence-scope. Safe to call multiple times. +---@param opts? PersistenceScope.Config function M.setup(opts) - M.config = vim.tbl_deep_extend("force", M.config, opts or {}) - M.config.base_dir = base_dir() - M.scope = providers.resolve(M.config.provider) - - local persistence = require("persistence") - persistence.setup({ - dir = session_dir(), - branch = M.config.branch, - need = M.config.need, - }) + local config = require("persistence_scope.config") + local scope = require("persistence_scope.scope") + + M.config = config.setup(opts) + M.scope = scope.resolve() + + local persistence = ensure_persistence() + if persistence then + persistence.setup({ + dir = scope.session_dir(), + branch = M.config.branch, + need = M.config.need, + }) - -- Upgrade persistence.select() to the scope-aware picker and add - -- persistence.load_file(path) for sourcing a specific session file. - persistence.select = M.select - persistence.load_file = M.load_file + -- Upgrade persistence.select() so existing keymaps & dashboards benefit + -- from scope-aware picking with no code changes, and expose load_file() + -- which upstream doesn't provide. + persistence.select = M.select + persistence.load_file = M.load_file + end create_commands() end diff --git a/lua/persistence_scope/pickers/snacks.lua b/lua/persistence_scope/pickers/snacks.lua index 6eef90e..2d80d47 100644 --- a/lua/persistence_scope/pickers/snacks.lua +++ b/lua/persistence_scope/pickers/snacks.lua @@ -8,6 +8,29 @@ function M.available() return snacks and snacks.picker ~= nil end +-- Path helpers + +local function home_alias(path) + if not path or path == "" then + return "unknown" + end + return vim.fn.fnamemodify(path, ":~") +end + +local function cwd_relative(path, cwd) + if not path or path == "" then + return "unknown" + end + if not cwd or path == cwd then + return path == cwd and "." or home_alias(path) + end + local prefix = cwd .. "/" + if path:sub(1, #prefix) == prefix then + return path:sub(#prefix + 1) + end + return home_alias(path) +end + local function tail_path(path) path = (path or ""):gsub("[/\\]+$", "") if path == "" then @@ -47,35 +70,118 @@ local function format_path(path) return ".." .. trimmed_parent .. "/" .. current end -local function format(item) +-- Preview (Snacks-specific markdown rendering) + +local function preview_line(lines, extmarks, text, marks) + lines[#lines + 1] = text + local row = #lines + for _, mark in ipairs(marks or {}) do + extmarks[#extmarks + 1] = { + row = row, + col = mark.col, + end_col = mark.end_col, + hl_group = mark.hl_group, + hl_mode = "replace", + priority = 200, + } + end +end + +local function preview_field(lines, extmarks, label, value, hl_group) + value = tostring(value or "none") + local text = ("%-8s %s"):format(label, value) + preview_line(lines, extmarks, text, { + { col = 0, end_col = #label, hl_group = "SnacksPickerDimmed" }, + { col = 9, end_col = #text, hl_group = hl_group }, + }) +end + +local function build_preview(item) + local lines = {} + local extmarks = {} + + preview_line(lines, extmarks, "# Session", { + { col = 0, end_col = 9, hl_group = "@markup.heading.1.markdown" }, + }) + preview_field(lines, extmarks, "Path", home_alias(item.file), "SnacksPickerComment") + preview_field(lines, extmarks, "CWD", home_alias(item.cwd), "SnacksPickerDirectory") + preview_field(lines, extmarks, "Scope", item.scope_label or "global", "SnacksPickerSpecial") + preview_field( + lines, + extmarks, + "Branch", + item.branch or "none", + item.branch and "SnacksPickerGitBranch" or "SnacksPickerComment" + ) + preview_field(lines, extmarks, "Modified", os.date("%Y-%m-%d %H:%M:%S", item.mtime), "SnacksPickerTime") + preview_line(lines, extmarks, "") + + local files_title = ("## Files (%d)"):format(#item.buffers) + preview_line(lines, extmarks, files_title, { + { col = 0, end_col = #files_title, hl_group = "@markup.heading.2.markdown" }, + }) + + if #item.buffers == 0 then + preview_line(lines, extmarks, " none parsed", { + { col = 2, end_col = 13, hl_group = "SnacksPickerComment" }, + }) + else + for _, file in ipairs(item.buffers) do + local path = cwd_relative(file, item.cwd) + local text = "- " .. path + preview_line(lines, extmarks, text, { + { col = 2, end_col = #text, hl_group = "SnacksPickerFile" }, + }) + end + end + + return { + text = table.concat(lines, "\n"), + ft = "markdown", + extmarks = extmarks, + loc = false, + } +end + +-- Row formatter + +local function format_row(item) local Snacks = require("snacks") local align = Snacks.picker.util.align local cwd = format_path(tail_path(item.cwd)) - local ret = {} - ret[#ret + 1] = { align(item.age or "", 7), "SnacksPickerTime" } - ret[#ret + 1] = { " " } - ret[#ret + 1] = { align(item.scope_label or "global", 24, { truncate = true }), "Identifier" } - ret[#ret + 1] = { " " } - ret[#ret + 1] = { align(cwd, path_width), "Directory" } - - ret[#ret + 1] = { " " } - ret[#ret + 1] = { align(item.buffer_summary or "", 44, { truncate = true }), "Comment" } - - ret[#ret + 1] = { " " } - ret[#ret + 1] = { align(item.branch or "", 22, { truncate = true }), "Number" } - return ret + return { + { align(item.age or "", 7), "SnacksPickerTime" }, + { " " }, + { align(item.scope_label or "global", 24, { truncate = true }), "Identifier" }, + { " " }, + { align(cwd, path_width), "Directory" }, + { " " }, + { align(item.buffer_summary or "", 44, { truncate = true }), "Comment" }, + { " " }, + { align(item.branch or "", 22, { truncate = true }), "Number" }, + } end +-- Entry point + function M.select(items, opts, on_confirm) opts = opts or {} local Snacks = require("snacks") + -- Attach preview payloads lazily so the cheap vim.ui fallback never pays + -- for them. + for _, item in ipairs(items) do + if not item.preview then + item.preview = build_preview(item) + end + end + local picker_opts = vim.tbl_deep_extend("force", opts.snacks or {}, { source = "persistence_scope", items = items, title = opts.title or "Sessions", - format = format, + format = format_row, preview = "preview", confirm = function(picker, item) picker:close() diff --git a/lua/persistence_scope/restore.lua b/lua/persistence_scope/restore.lua new file mode 100644 index 0000000..7a5b979 --- /dev/null +++ b/lua/persistence_scope/restore.lua @@ -0,0 +1,55 @@ +local scope = require("persistence_scope.scope") +local session = require("persistence_scope.session") +local util = require("persistence_scope.util") + +local M = {} + +---Restore the best session for the current cwd + scope. See +---|persistence-scope-restore| for the full decision tree. +---@param select fun(opts: table) picker entry point used for ambiguous cases +---@return boolean +function M.run(select) + local cwd = util.normalize(vim.fn.getcwd()) + local candidates = session.list({ cwd = cwd }) + + if scope.current and scope.current.dir then + local same_scope = vim.tbl_filter(function(item) + return item.scope_dir == scope.current.dir + end, candidates) + + if #same_scope > 0 then + if session.recent_count(same_scope) > 1 then + select({ + items = same_scope, + title = ("Recent sessions for %s"):format(scope.current.label), + }) + return true + end + return session.load_file(util.newest(same_scope).file) + end + + if #candidates > 0 then + select({ + items = candidates, + title = "Sessions for this directory", + }) + return true + end + elseif #candidates == 1 then + return session.load_file(candidates[1].file) + elseif #candidates > 1 then + if session.recent_count(candidates) > 1 then + select({ + items = candidates, + title = "Sessions for this directory", + }) + return true + end + return session.load_file(util.newest(candidates).file) + end + + vim.notify("persistence-scope: no saved session for this scope or cwd", vim.log.levels.INFO) + return false +end + +return M diff --git a/lua/persistence_scope/scope.lua b/lua/persistence_scope/scope.lua new file mode 100644 index 0000000..1993286 --- /dev/null +++ b/lua/persistence_scope/scope.lua @@ -0,0 +1,54 @@ +local config = require("persistence_scope.config") +local providers = require("persistence_scope.providers") +local util = require("persistence_scope.util") + +local M = {} + +---@class PersistenceScope.Scope +---@field kind string identifier for the provider (e.g. "tmux_window_name") +---@field label string human-readable label shown in the picker +---@field dir string directory name appended to base_dir +---@field meta table? arbitrary extra info exposed to consumers + +---The resolved scope for this Neovim instance (or nil for global). +---@type PersistenceScope.Scope? +M.current = nil + +---@return string +function M.base_dir() + return util.with_slash(config.options.base_dir) +end + +---Directory where persistence.nvim should save sessions for the current scope. +---@return string +function M.session_dir() + if M.current and M.current.dir then + return util.join(M.base_dir(), M.current.dir) .. "/" + end + return M.base_dir() +end + +---Re-resolve the current scope from the configured provider. +---@return PersistenceScope.Scope? +function M.resolve() + M.current = providers.resolve(config.options.provider) + return M.current +end + +---Given an absolute session file path, return `(scope_dir, scope_label)` +---where `scope_dir` is nil and `scope_label` is "global" for unscoped files. +---@param file string +---@return string?, string +function M.from_file(file) + local parent = vim.fn.fnamemodify(file, ":h") + if util.normalize(parent) == util.normalize(M.base_dir()) then + return nil, "global" + end + + local dir = vim.fn.fnamemodify(parent, ":t") + -- Strip the common "tmux-" prefix for nicer display. + local label = dir:gsub("^tmux%-", "") + return dir, label +end + +return M diff --git a/lua/persistence_scope/session.lua b/lua/persistence_scope/session.lua new file mode 100644 index 0000000..535164d --- /dev/null +++ b/lua/persistence_scope/session.lua @@ -0,0 +1,123 @@ +local config = require("persistence_scope.config") +local scope = require("persistence_scope.scope") +local util = require("persistence_scope.util") + +local M = {} + +---@class PersistenceScope.SessionItem +---@field file string absolute path to the .vim session file +---@field session string alias for `file` (compatibility with persistence.nvim items) +---@field cwd string normalized cwd the session was saved from +---@field branch string? git branch encoded in the session filename +---@field scope_dir string? sanitized scope directory ("tmux-api" etc.), nil for global +---@field scope_label string human-readable scope label ("api", "global", …) +---@field mtime number modification time (seconds since epoch) +---@field age string short relative age ("3h", "2d", …) +---@field buffers string[] absolute paths of buffers parsed out of the session file +---@field buffer_summary string short comma-joined buffer summary +---@field text string search haystack for the picker +---@field preview table? optional Snacks preview payload + +---@param file string +---@return PersistenceScope.SessionItem? +function M.item_from_file(file) + local mtime = util.stat(file) + if not mtime then + return nil + end + + local name_cwd, branch = util.decode_session_name(file) + local file_cwd, buffers = util.parse_session_file(file) + local scope_dir, scope_label = scope.from_file(file) + local cwd = file_cwd or name_cwd + if not cwd then + return nil + end + + local item = { + file = file, + session = file, + cwd = cwd, + branch = branch, + scope_dir = scope_dir, + scope_label = scope_label, + mtime = mtime, + age = util.reltime(mtime), + buffers = buffers, + buffer_summary = util.buffer_summary(buffers), + } + + item.text = table.concat({ + item.scope_label or "global", + item.cwd, + item.branch or "", + item.buffer_summary, + table.concat(item.buffers, " "), + }, " ") + + return item +end + +---@param opts { cwd?: string, scope_dir?: string, items?: PersistenceScope.SessionItem[] } +---@return PersistenceScope.SessionItem[] +function M.list(opts) + opts = opts or {} + if opts.items then + return opts.items + end + + local wanted_cwd = util.normalize(opts.cwd) + local wanted_scope = opts.scope_dir + local items = {} + + for _, file in ipairs(util.glob_sessions(scope.base_dir())) do + local item = M.item_from_file(file) + if + item + and (not wanted_cwd or item.cwd == wanted_cwd) + and (not wanted_scope or item.scope_dir == wanted_scope) + then + items[#items + 1] = item + end + end + + table.sort(items, function(a, b) + if a.mtime == b.mtime then + return a.file < b.file + end + return a.mtime > b.mtime + end) + return items +end + +---Source a session file, firing the usual persistence Pre/Post events. +---@param file string +---@return boolean +function M.load_file(file) + if not file or vim.fn.filereadable(file) == 0 then + return false + end + + local persistence = require("persistence") + persistence.fire("LoadPre") + vim.cmd("silent! source " .. vim.fn.fnameescape(file)) + persistence.fire("LoadPost") + return true +end + +---Count how many items in `items` were modified within `recent_seconds`. +---@param items PersistenceScope.SessionItem[] +---@return integer +function M.recent_count(items) + local now = os.time() + local window = config.options.recent_seconds + local count = 0 + for _, item in ipairs(items) do + if now - item.mtime <= window then + count = count + 1 + end + end + return count +end + +return M From d0a9164940d782aeb1a32dff814d27ef7761d187 Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 04:07:24 -0700 Subject: [PATCH 3/9] feat: register user commands in plugin/ for early discovery Previously :PersistenceScopeRestore and :PersistenceScopeSelect were only created inside setup(), which meant they did not appear in :command output (or tab completion) until setup() ran. Define them in plugin/persistence_scope.lua at startup with lazy-required handlers, so they are discoverable immediately. setup() re-registers them with force=true (identical behavior, just cleaner discovery). Guarded with vim.g.loaded_persistence_scope to prevent double loading. --- plugin/persistence_scope.lua | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/plugin/persistence_scope.lua b/plugin/persistence_scope.lua index 2a8cc73..348c43f 100644 --- a/plugin/persistence_scope.lua +++ b/plugin/persistence_scope.lua @@ -1 +1,24 @@ --- Commands are created by require("persistence_scope").setup(). +-- persistence-scope.nvim — bootstrap +-- +-- User commands are defined here so they're discoverable even before +-- `require("persistence_scope").setup()` has been called. `setup()` will +-- replace them with the final (identical) handlers. + +if vim.g.loaded_persistence_scope == 1 then + return +end +vim.g.loaded_persistence_scope = 1 + +local function lazy(name) + return function() + require("persistence_scope")[name]() + end +end + +vim.api.nvim_create_user_command("PersistenceScopeRestore", lazy("restore"), { + desc = "Restore the best matching session for cwd + scope", +}) + +vim.api.nvim_create_user_command("PersistenceScopeSelect", lazy("select"), { + desc = "Open the persistence-scope session picker", +}) From 75dcee8571b08aee35e14da3f1eb00a495fc6083 Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 04:07:31 -0700 Subject: [PATCH 4/9] feat: add :checkhealth persistence_scope Reports everything a user needs to self-diagnose: - Neovim version (errors below 0.10) - folke/persistence.nvim installed (errors if missing, warns if its fire() function is missing) - folke/snacks.nvim installed (info-only, optional dependency) - Whether setup() has been called or defaults are in use - Resolved scope: kind, label, and the target sessions directory (or 'global' / base_dir when no scope is resolved) - Whether the editor is running inside tmux - Count of session files currently under base_dir Uses the modern vim.health.{start,ok,warn,error,info} API with a fallback to vim.health.report_* for compatibility. --- lua/persistence_scope/health.lua | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 lua/persistence_scope/health.lua diff --git a/lua/persistence_scope/health.lua b/lua/persistence_scope/health.lua new file mode 100644 index 0000000..83ecfd9 --- /dev/null +++ b/lua/persistence_scope/health.lua @@ -0,0 +1,69 @@ +local M = {} + +local start = vim.health.start or vim.health.report_start +local ok = vim.health.ok or vim.health.report_ok +local warn = vim.health.warn or vim.health.report_warn +local error = vim.health.error or vim.health.report_error +local info = vim.health.info or vim.health.report_info + +function M.check() + start("persistence-scope.nvim") + + -- Neovim version + if vim.fn.has("nvim-0.10") == 1 then + ok("Neovim >= 0.10") + else + error("Neovim 0.10+ is required (using " .. tostring(vim.version()) .. ")") + end + + -- persistence.nvim + local has_persistence, persistence = pcall(require, "persistence") + if has_persistence then + ok("folke/persistence.nvim is installed") + if type(persistence.fire) ~= "function" then + warn("persistence.nvim is missing `fire()` — load events may not be emitted") + end + else + error("folke/persistence.nvim is not installed (required dependency)") + return + end + + -- Snacks (optional) + local has_snacks = pcall(require, "snacks") + if has_snacks then + ok("folke/snacks.nvim is installed — rich picker enabled") + else + info("folke/snacks.nvim is not installed — falling back to vim.ui.select") + end + + -- Setup called? + local config = require("persistence_scope.config") + if config.options == config.defaults then + info("`require('persistence_scope').setup()` has not been called — using defaults") + else + ok("Configured") + end + + -- Scope + local scope = require("persistence_scope.scope") + if scope.current then + ok(("Scope resolved: %s (%s) → %s"):format(scope.current.label, scope.current.kind, scope.session_dir())) + else + info("No scope resolved — sessions are saved in `base_dir` directly") + info(("Sessions directory: %s"):format(scope.session_dir())) + end + + -- tmux + if vim.env.TMUX and vim.env.TMUX ~= "" then + ok("Running inside tmux (" .. (vim.env.TMUX_PANE or "?") .. ")") + else + info("Not running inside tmux — built-in tmux providers will return nil") + end + + -- Existing sessions + local util = require("persistence_scope.util") + local files = util.glob_sessions(scope.base_dir()) + info(("Found %d session file(s) under %s"):format(#files, scope.base_dir())) +end + +return M From 9a84baea0604253f66bbb78f0dc745eafe4d08a0 Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 04:07:36 -0700 Subject: [PATCH 5/9] chore: add stylua, editorconfig, and luacheck configs stylua.toml 2-space indent, 120-column, double quotes .editorconfig LF, trim trailing whitespace, 2-space Lua, tab Makefile .luacheckrc declares vim and Snacks as known globals, sets max_line_length=120, ignores a few benign Lua warnings These set baseline expectations for contributors and are consumed by the CI workflow added in the following commit. --- .editorconfig | 17 +++++++++++++++++ .luacheckrc | 27 +++++++++++++++++++++++++++ stylua.toml | 7 +++++++ 3 files changed, 51 insertions(+) create mode 100644 .editorconfig create mode 100644 .luacheckrc create mode 100644 stylua.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3a72288 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.lua] +indent_style = space +indent_size = 2 + +[*.{md,txt}] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..f296297 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,27 @@ +-- vim: ft=lua tw=80 + +stds.nvim = { + globals = { + "vim", + }, + read_globals = { + "Snacks", + }, +} + +std = "min+nvim" +cache = true + +self = false +codes = true +max_line_length = 120 + +ignore = { + "212", -- unused argument + "631", -- max_line_length +} + +files["lua/persistence_scope/util.lua"] = { + -- shadowing `error` from the global namespace is intentional in some lua helpers + ignore = { "431" }, +} diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..cee468f --- /dev/null +++ b/stylua.toml @@ -0,0 +1,7 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +collapse_simple_statement = "Never" From d156a04711a4a149b6acd9b8beeaf44ca0b78bfa Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 04:07:42 -0700 Subject: [PATCH 6/9] ci: add stylua, luacheck, and headless smoke test workflow Three jobs run on every push to main and on pull requests: stylua stylua --check . fails the build on formatting drift lint luacheck lua plugin catches unused vars, shadowing, etc. smoke headless Neovim across 0.10.4 / stable / nightly clones folke/persistence.nvim, runs runtime plugin/persistence_scope, calls setup() with a stub provider, asserts the public API is present, and runs :checkhealth persistence_scope The smoke test catches load-time regressions across the supported Neovim version matrix. Behavioral regressions still need targeted tests, which are intentionally left as a follow-up. --- .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..10e23dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + stylua: + name: stylua + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: latest + args: --check . + + lint: + name: luacheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.1" + - uses: leafo/gh-actions-luarocks@v4 + - run: luarocks install luacheck + - run: luacheck lua plugin + + smoke: + name: load (nvim ${{ matrix.nvim }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + nvim: ["v0.10.4", "stable", "nightly"] + steps: + - uses: actions/checkout@v4 + with: + path: persistence-scope.nvim + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.nvim }} + - name: Clone folke/persistence.nvim + run: git clone --depth=1 https://github.com/folke/persistence.nvim + - name: Headless smoke test + run: | + nvim --headless --noplugin -u NONE \ + --cmd "set rtp+=persistence.nvim" \ + --cmd "set rtp+=persistence-scope.nvim" \ + --cmd "runtime plugin/persistence_scope.lua" \ + -c "lua require('persistence_scope').setup({ provider = function() return nil end })" \ + -c "lua assert(type(require('persistence_scope').restore) == 'function')" \ + -c "checkhealth persistence_scope" \ + -c "qa!" From 373dfb96866b7fa9f5a3a98f4b1f17e80161c602 Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 04:07:50 -0700 Subject: [PATCH 7/9] docs: rewrite vimdoc with helptags for Lua API and new options Previously the Lua API was a single unanchored block in the help file. Each function now has its own tag so :help works for individual entries: :help persistence_scope.restore() :help persistence_scope.select() :help persistence_scope.sessions() :help persistence_scope.load_file() Also adds: *persistence-scope-need-option* documents 'need', forwarded to persistence.nvim *persistence-scope-custom-provider* direct anchor for the custom provider example *persistence-scope-troubleshooting* new section pointing at :checkhealth persistence_scope Reformatted to follow standard Vim help conventions (>/< code blocks, column-aligned tags) and regenerated doc/tags accordingly. --- doc/persistence-scope.txt | 273 ++++++++++++++++++++++---------------- doc/tags | 9 +- 2 files changed, 164 insertions(+), 118 deletions(-) diff --git a/doc/persistence-scope.txt b/doc/persistence-scope.txt index 57de901..0369c96 100644 --- a/doc/persistence-scope.txt +++ b/doc/persistence-scope.txt @@ -11,6 +11,7 @@ CONTENTS *persistence-scope-contents* 6. Configuration |persistence-scope-configuration| 7. Providers |persistence-scope-providers| 8. Restore Behavior |persistence-scope-restore| +9. Troubleshooting |persistence-scope-troubleshooting| ============================================================================== OVERVIEW *persistence-scope-overview* @@ -19,192 +20,230 @@ persistence-scope.nvim lets one working directory have multiple independent persistence.nvim sessions by saving each session under a scope. The default scope is the current tmux window name. -Example: -> - ~/.local/state/nvim/sessions/ - tmux-api/%home%me%project.vim - tmux-ui/%home%me%project.vim +Example: > + + ~/.local/state/nvim/sessions/ + tmux-api/%home%me%project.vim + tmux-ui/%home%me%project.vim < -This is useful when you keep several Neovim instances open in the same -repository for different threads of work. +Useful when you keep several Neovim instances open in the same repository for +different threads of work (one window for the API, one for the UI, one for a +code review, etc.). ============================================================================== REQUIREMENTS *persistence-scope-requirements* - Neovim 0.10+ -- folke/persistence.nvim -- folke/snacks.nvim, optional, for the rich picker -- tmux, optional, for the built-in tmux providers +- folke/persistence.nvim (required) +- folke/snacks.nvim (optional, enables the rich picker) +- tmux (optional, for the built-in tmux providers) ============================================================================== INSTALLATION *persistence-scope-installation* -With lazy.nvim: -> - { - "avgvstvs96/persistence-scope.nvim", - dependencies = { - "folke/persistence.nvim", - "folke/snacks.nvim", - }, - lazy = false, - opts = {}, - } +With lazy.nvim: > + + { + "avgvstvs96/persistence-scope.nvim", + dependencies = { + "folke/persistence.nvim", + "folke/snacks.nvim", + }, + lazy = false, + opts = {}, + } < -persistence-scope.nvim calls require("persistence").setup() for you. If you -currently have a separate persistence.nvim setup block, move those options into -this plugin's opts. +persistence-scope.nvim calls `require("persistence").setup()` for you. If you +currently have a separate persistence.nvim setup block, move those options +into this plugin's `opts`. ============================================================================== COMMANDS *persistence-scope-commands* - *:PersistenceScopeRestore* + *:PersistenceScopeRestore* :PersistenceScopeRestore Restore the best matching session for the current working directory and - current scope. + current scope. Opens a picker when the choice is ambiguous. - *:PersistenceScopeSelect* + *:PersistenceScopeSelect* :PersistenceScopeSelect - Open the session picker. + Always open the session picker. ============================================================================== LUA API *persistence-scope-api* -> - require("persistence_scope").restore() - require("persistence_scope").select() - require("persistence_scope").sessions() - require("persistence_scope").load_file(path) + *persistence_scope.restore()* +require("persistence_scope").restore() + Smart restore. See |persistence-scope-restore|. + + *persistence_scope.select()* +require("persistence_scope").select({opts}) + Open the session picker. Always opens, even when there is a single match. + {opts} may contain: > + + { + items = PersistenceScope.SessionItem[]?, -- pre-filtered items + title = string?, -- picker title + picker = "auto"|"snacks"|"vim_ui"?, -- override for this call + snacks = table?, -- extra Snacks options + } < + *persistence_scope.sessions()* +require("persistence_scope").sessions({opts}) + Return the list of session items, optionally filtered by {opts}: > + + { cwd = string?, scope_dir = string? } +< + + *persistence_scope.load_file()* +require("persistence_scope").load_file({path}) + Source a specific session file. Fires `PersistenceLoadPre` and + `PersistenceLoadPost` so other plugins can hook into the restore. + ============================================================================== CONFIGURATION *persistence-scope-configuration* -Defaults: -> - require("persistence_scope").setup({ - provider = "tmux_window_name", - picker = "auto", - base_dir = vim.fn.stdpath("state") .. "/sessions/", - branch = true, - recent_seconds = 4 * 60 * 60, - snacks = {}, - }) +Defaults (pass any subset to `setup()`): > + + require("persistence_scope").setup({ + provider = "tmux_window_name", + picker = "auto", + base_dir = vim.fn.stdpath("state") .. "/sessions/", + branch = true, + need = nil, + recent_seconds = 4 * 60 * 60, + snacks = {}, + }) < +All options are optional — `setup()` with no arguments works fine. + ------------------------------------------------------------------------------ -provider *persistence-scope-provider-option* +provider *persistence-scope-provider-option* -The scope used to choose the session directory. -> - provider = "tmux_window_name" -< +The scope used to choose the session directory. Either the name of a built-in +provider (see |persistence-scope-providers|) or a function returning a scope +table (see |persistence-scope-custom-provider|). ------------------------------------------------------------------------------ -picker *persistence-scope-picker-option* +picker *persistence-scope-picker-option* -Picker used by select() and ambiguous restores. -> - picker = "auto" -- Snacks when available, otherwise vim.ui.select - picker = "snacks" - picker = "vim_ui" +Picker used by |persistence_scope.select()| and ambiguous restores: > + + picker = "auto" -- Snacks when available, otherwise vim.ui.select + picker = "snacks" -- force Snacks + picker = "vim_ui" -- force vim.ui.select < -The Snacks picker shows age, scope, cwd, branch, buffer summary, and a preview. +The Snacks picker shows age, scope, cwd, branch, buffer summary, and a +preview. ------------------------------------------------------------------------------ -base_dir *persistence-scope-base-dir-option* +base_dir *persistence-scope-base-dir-option* -Base directory for all session files. -> - base_dir = vim.fn.stdpath("state") .. "/sessions/" -< +Base directory for all session files. The current scope's directory name is +appended for the actual save location. ------------------------------------------------------------------------------ -branch *persistence-scope-branch-option* +branch *persistence-scope-branch-option* -Passed to persistence.nvim. When enabled, non-main branches get separate +Forwarded to persistence.nvim. When enabled, non-main branches get separate session files. -> - branch = true -< ------------------------------------------------------------------------------ -recent_seconds *persistence-scope-recent-seconds-option* +need *persistence-scope-need-option* -When more than one session for the current cwd and scope was modified recently, -restore opens the picker instead of guessing. -> - recent_seconds = 4 * 60 * 60 -< +Forwarded to persistence.nvim. Minimum number of file buffers required for +autosave. `nil` uses the upstream default (1). Set to 0 to always save. ------------------------------------------------------------------------------ -persistence.nvim integration *persistence-scope-integration* +recent_seconds *persistence-scope-recent-seconds-option* -This plugin always wires itself into require("persistence"): - * upgrades .select() to the scope-aware / Snacks-capable picker - * adds .load_file(path) to source a specific session file with proper - LoadPre/LoadPost events fired -> - require("persistence").select() - require("persistence").load_file(path) -< -No other upstream functions are touched. You can equivalently call -require("persistence_scope").select() / .load_file() directly. +When more than one session for the current cwd and scope was modified +recently, restore opens the picker instead of guessing. ------------------------------------------------------------------------------ -snacks *persistence-scope-snacks-option* +snacks *persistence-scope-snacks-option* -Extra options passed to the Snacks picker. -> - snacks = { - -- any Snacks.picker option - } -< +Extra options forwarded to the Snacks picker. Any field accepted by +`Snacks.picker()` works here. + +------------------------------------------------------------------------------ +persistence.nvim integration *persistence-scope-integration* + +This plugin always wires itself into `require("persistence")`: + * upgrades `.select()` to the scope-aware / Snacks-capable picker + * adds `.load_file(path)` to source a specific session file with proper + LoadPre/LoadPost events fired + +No other upstream functions are touched. You can equivalently call +`require("persistence_scope").select()` / `.load_file()` directly. ============================================================================== PROVIDERS *persistence-scope-providers* -Built-in providers: -> - "tmux_window_name" - "tmux_window_index" - "tmux_pane_id" - "tmux_pane_index" - "tmux_session_window" +Built-in providers (the value passed as `provider`): + + "tmux_window_name" — #{window_name} (default) + "tmux_window_index" — #{window_index} + "tmux_pane_id" — #{pane_id} + "tmux_pane_index" — #{pane_index} + "tmux_session_window" — #{session_name}:#{window_name} + +If a provider can't resolve a scope (e.g. tmux is not running) it returns +`nil` and sessions are saved directly under `base_dir` with no extra +subdirectory. + + *persistence-scope-custom-provider* +Custom providers return a scope table: > + + provider = function() + return { + kind = "custom", -- identifier for the provider + label = "my-scope", -- shown in the picker + dir = "custom-my-scope", -- subdirectory under base_dir + meta = {}, -- arbitrary extra info + } + end < -Custom providers return a scope table: -> - provider = function() - return { - kind = "custom", - label = "my-scope", - dir = "custom-my-scope", - meta = {}, - } - end -< - -If a provider returns nil, sessions are saved in base_dir without an extra -scope directory. - ============================================================================== -RESTORE BEHAVIOR *persistence-scope-restore* +RESTORE BEHAVIOR *persistence-scope-restore* -:PersistenceScopeRestore uses this order: +`:PersistenceScopeRestore` uses this order: 1. Find sessions for the current working directory. 2. Prefer sessions in the current scope. -3. If more than one current-scope session was modified within recent_seconds, - open the picker. +3. If more than one current-scope session was modified within + `recent_seconds`, open the picker. 4. Otherwise load the newest current-scope session. 5. If no current-scope session exists, open a picker for the current working directory across all scopes. -6. Outside a resolved scope, load the only current-directory session or ask when - there are multiple. +6. Outside a resolved scope, load the only current-directory session or ask + when there are multiple. + +============================================================================== +TROUBLESHOOTING *persistence-scope-troubleshooting* + +Run > + + :checkhealth persistence_scope +< +to see Neovim version, dependency status, the resolved scope, where sessions +are being saved, and how many session files already exist. + +If scoping doesn't seem to be working: + +1. Confirm `:checkhealth` shows a non-`global` scope. +2. Make sure nothing else in your config calls `require("persistence").setup()` + *after* this plugin — that will overwrite `dir` and silently break scoping. +3. Inspect the contents of `base_dir`: > + + :lua = require("persistence_scope").config.base_dir +< ============================================================================== vim:tw=78:ts=8:ft=help:norl: diff --git a/doc/tags b/doc/tags index 6768034..0e002f4 100644 --- a/doc/tags +++ b/doc/tags @@ -6,9 +6,11 @@ persistence-scope-branch-option persistence-scope.txt /*persistence-scope-branch persistence-scope-commands persistence-scope.txt /*persistence-scope-commands* persistence-scope-configuration persistence-scope.txt /*persistence-scope-configuration* persistence-scope-contents persistence-scope.txt /*persistence-scope-contents* +persistence-scope-custom-provider persistence-scope.txt /*persistence-scope-custom-provider* persistence-scope-installation persistence-scope.txt /*persistence-scope-installation* -persistence-scope-overview persistence-scope.txt /*persistence-scope-overview* persistence-scope-integration persistence-scope.txt /*persistence-scope-integration* +persistence-scope-need-option persistence-scope.txt /*persistence-scope-need-option* +persistence-scope-overview persistence-scope.txt /*persistence-scope-overview* persistence-scope-picker-option persistence-scope.txt /*persistence-scope-picker-option* persistence-scope-provider-option persistence-scope.txt /*persistence-scope-provider-option* persistence-scope-providers persistence-scope.txt /*persistence-scope-providers* @@ -16,4 +18,9 @@ persistence-scope-recent-seconds-option persistence-scope.txt /*persistence-scop persistence-scope-requirements persistence-scope.txt /*persistence-scope-requirements* persistence-scope-restore persistence-scope.txt /*persistence-scope-restore* persistence-scope-snacks-option persistence-scope.txt /*persistence-scope-snacks-option* +persistence-scope-troubleshooting persistence-scope.txt /*persistence-scope-troubleshooting* persistence-scope.nvim persistence-scope.txt /*persistence-scope.nvim* +persistence_scope.load_file() persistence-scope.txt /*persistence_scope.load_file()* +persistence_scope.restore() persistence-scope.txt /*persistence_scope.restore()* +persistence_scope.select() persistence-scope.txt /*persistence_scope.select()* +persistence_scope.sessions() persistence-scope.txt /*persistence_scope.sessions()* From ac795656d2980ea765402e2bc16eae4989392387 Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 04:08:03 -0700 Subject: [PATCH 8/9] docs(readme): add vim.pack install, need option, troubleshooting - vim.pack install snippet for Neovim 0.12+ users (native package manager) right after the lazy.nvim snippet - 'Sessions are still written by folke/persistence.nvim' framing line under the directory diagram so readers immediately understand this is a layer on top of persistence.nvim, not a replacement - New Troubleshooting section pointing at :checkhealth persistence_scope with a 3-step debug checklist - Config block: document the 'need' option (forwarded to persistence.nvim) and clarify that setup() with no arguments works fine - Minor copy clean-ups in the config block and IMPORTANT callout - TOC entry for the new Troubleshooting section --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 55cb5c6..7ffb745 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ and restoration. └── %home%me%project.vim ``` +> Sessions are still written by `folke/persistence.nvim` — this plugin only +> redirects them into a per-scope subdirectory and adds a smarter picker on top. + > Not a tmux user? Bring your own scope — any Lua function works (see [Custom providers](#custom-providers)). ## 📚 Contents @@ -49,6 +52,7 @@ and restoration. - [Providers](#-providers) - [Restore Behavior](#-restore-behavior) - [Examples](#-examples) +- [Troubleshooting](#-troubleshooting) - [FAQ](#-faq) - [Acknowledgements](#-acknowledgements) - [License](#-license) @@ -93,10 +97,32 @@ With [`lazy.nvim`](https://github.com/folke/lazy.nvim): } ``` +With Neovim's native package manager (`vim.pack`, Neovim `>= 0.12`): + +```lua +vim.pack.add({ + { src = "https://github.com/folke/persistence.nvim" }, + { src = "https://github.com/folke/snacks.nvim" }, -- optional, for the rich picker + { src = "https://github.com/avgvstvs96/persistence-scope.nvim" }, +}) + +require("persistence_scope").setup({ + provider = "tmux_window_name", +}) + +vim.keymap.set("n", "qr", function() + require("persistence_scope").restore() +end, { desc = "Restore session" }) + +vim.keymap.set("n", "qs", function() + require("persistence_scope").select() +end, { desc = "Select session" }) +``` + >[!IMPORTANT] >`persistence-scope.nvim` calls `require("persistence").setup()` for you. > If you already configure `persistence.nvim` separately, move those options -> into this plugin's `opts` and remove the standalone setup call. +> into this plugin's `opts` / `setup()` call and remove the standalone setup call. ## ⚡ Quick Start @@ -145,8 +171,8 @@ Defaults — pass any subset to `opts`: ```lua require("persistence_scope").setup({ - -- Scope used to choose the session directory. - -- Built-in providers (see below) or a custom function. + -- Scope used to choose the session directory. Either the name of a + -- built-in provider (see below) or a function returning a scope table. provider = "tmux_window_name", -- Picker used by `select()` and ambiguous restores. @@ -155,13 +181,18 @@ require("persistence_scope").setup({ -- "vim_ui" → force vim.ui.select picker = "auto", - -- Base directory for all session files. + -- Base directory for all session files. The current scope's directory + -- is appended to this for the actual save location. base_dir = vim.fn.stdpath("state") .. "/sessions/", -- Forwarded to persistence.nvim. When true, non-main branches get -- their own session files. branch = true, + -- Forwarded to persistence.nvim. Minimum file buffers required for + -- autosave. `nil` uses the upstream default (1). Set to 0 to always save. + need = nil, + -- If more than one current-scope session was modified within this -- window of time, restore opens the picker instead of guessing. recent_seconds = 4 * 60 * 60, @@ -171,6 +202,9 @@ require("persistence_scope").setup({ }) ``` +All options are optional — `require("persistence_scope").setup()` with no +arguments works fine and uses the defaults shown above. + ## 🔌 Providers A provider is just a function that returns a **scope table** (or `nil`): @@ -235,6 +269,20 @@ require("persistence_scope").setup({ If nothing matches at all, you get a friendly `vim.notify` and no session is sourced. +## 🩺 Troubleshooting + +Run `:checkhealth persistence_scope` — it reports your Neovim version, whether +`persistence.nvim` / `snacks.nvim` are installed, the resolved scope, where +sessions are being saved, and how many session files already exist. + +If scoping doesn't seem to be working: + +1. Confirm `:checkhealth` shows a non-`global` scope. +2. Make sure nothing else in your config calls `require("persistence").setup()` + *after* this plugin loads (see the FAQ entry below). +3. Check `:lua = require("persistence_scope").config.base_dir` and inspect the + subdirectories there. + ## 🧪 Examples ### Scope by tmux window name (default) From 0c6da68db5b307ed247c195e34060b922f4b37a7 Mon Sep 17 00:00:00 2001 From: Bassim Shahidy <122117267+AVGVSTVS96@users.noreply.github.com> Date: Mon, 18 May 2026 05:02:31 -0700 Subject: [PATCH 9/9] style: apply stylua formatting Fixes stylua --check violations caught by the new CI workflow: - util.lua and vim_ui.lua predate this PR; one nested table.concat call in each exceeded the 120-column budget and got reflowed. - session.lua is new in this PR; a long 3-clause if condition got collapsed onto a single line. All formatting choices are stylua's defaults under the repo's stylua.toml (2-space, 120-col, double quotes). --- lua/persistence_scope/pickers/vim_ui.lua | 9 ++++++--- lua/persistence_scope/session.lua | 6 +----- lua/persistence_scope/util.lua | 9 ++++++--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lua/persistence_scope/pickers/vim_ui.lua b/lua/persistence_scope/pickers/vim_ui.lua index dc8fb95..59daf6d 100644 --- a/lua/persistence_scope/pickers/vim_ui.lua +++ b/lua/persistence_scope/pickers/vim_ui.lua @@ -54,9 +54,12 @@ function M.select(items, opts, on_confirm) local branch = item.branch and pad("Branch: " .. item.branch, 26) or "" local count = item.buffers and #item.buffers or 0 local buffers = pad(("%-4s Buffers: %s"):format(("[%d]"):format(count), item.buffer_summary or ""), 56) - return table.concat(vim.tbl_filter(function(part) - return part ~= "" - end, { age, scope, cwd, buffers, branch }), " ") + return table.concat( + vim.tbl_filter(function(part) + return part ~= "" + end, { age, scope, cwd, buffers, branch }), + " " + ) end, }, function(item) if item then diff --git a/lua/persistence_scope/session.lua b/lua/persistence_scope/session.lua index 535164d..78c2bf0 100644 --- a/lua/persistence_scope/session.lua +++ b/lua/persistence_scope/session.lua @@ -72,11 +72,7 @@ function M.list(opts) for _, file in ipairs(util.glob_sessions(scope.base_dir())) do local item = M.item_from_file(file) - if - item - and (not wanted_cwd or item.cwd == wanted_cwd) - and (not wanted_scope or item.scope_dir == wanted_scope) - then + if item and (not wanted_cwd or item.cwd == wanted_cwd) and (not wanted_scope or item.scope_dir == wanted_scope) then items[#items + 1] = item end end diff --git a/lua/persistence_scope/util.lua b/lua/persistence_scope/util.lua index 608da9d..68dce6f 100644 --- a/lua/persistence_scope/util.lua +++ b/lua/persistence_scope/util.lua @@ -4,9 +4,12 @@ local uv = vim.uv or vim.loop function M.join(...) local parts = { ... } - local path = table.concat(vim.tbl_filter(function(part) - return part and part ~= "" - end, parts), "/") + local path = table.concat( + vim.tbl_filter(function(part) + return part and part ~= "" + end, parts), + "/" + ) return path:gsub("/+", "/") end