8000 mini.nvim/lua/mini/sessions.lua at main · CRAG666/mini.nvim · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
628 lines (534 loc) · 20.9 KB

sessions.lua

File metadata and controls

628 lines (534 loc) · 20.9 KB
-- MIT License Copyright (c) 2021 Evgeni Chasnovski
-- Documentation ==============================================================
--- Lua module for minimal session management (read, write, delete), which
--- works using |mksession| (meaning 'sessionoptions' is fully respected).
--- This is intended as a drop-in Lua replacement for session management part
--- of [mhinz/vim-startify](https://github.com/mhinz/vim-startify) (works out
--- of the box with sessions created by it). Implements both global (from
--- configured directory) and local (from current directory) sessions.
---
--- Key design ideas:
--- - Sessions are represented by readable files (results of applying
--- |mksession|). There are two kinds of sessions:
--- - Global: any file inside a configurable directory.
--- - Local: configurable file inside current working directory (|getcwd|).
--- - All session files are detected during `MiniSessions.setup()` with session
--- names being file names (including their possible extension).
--- - Store information about detected sessions in separate table
--- (|MiniSessions.detected|) and operate only on it. Meaning if this
--- information changes, there will be no effect until next detection. So to
--- avoid confusion, don't directly use |mksession| and |source| for writing
--- and reading sessions files.
---
--- Features:
--- - Autoread default session (local if detected, latest otherwise) if Neovim
--- was called without intention to show something else.
--- - Autowrite current session before quitting Neovim.
--- - Configurable severity level of all actions.
---
--- # Setup~
---
--- This module needs a setup with `require('mini.sessions').setup({})`
--- (replace `{}` with your `config` table). It will create global Lua table
--- `MiniSessions` which you can use for scripting or manually (with
--- `:lua MiniSessions.*`).
---
--- See |MiniSessions.config| for `config` structure and default values.
---
--- # Disabling~
---
--- To disable core functionality, set `g:minisessions_disable` (globally) or
--- `b:minisessions_disable` (for a buffer) to `v:true`. Considering high
--- number of different scenarios and customization intentions, writing exact
--- rules for disabling module's functionality is left to user. See
--- |mini.nvim-disabling-recipes| for common recipes.
---@tag mini.sessions
---@tag MiniSessions
---@toc_entry Session management
-- Module definition ==========================================================
local MiniSessions = {}
local H = { path_sep = package.config:sub(1, 1) }
--- Module setup
---
---@param config table Module config table. See |MiniSessions.config|.
---
---@usage `require('mini.sessions').setup({})` (replace `{}` with your `config` table)
function MiniSessions.setup(config)
-- Export module
_G.MiniSessions = MiniSessions
-- Setup config
config = H.setup_config(config)
-- Apply config
H.apply_config(config)
-- Module behavior
vim.api.nvim_exec(
[[augroup MiniSessions
au!
au VimEnter * ++nested ++once lua MiniSessions.on_vimenter()
augroup END]],
false
)
if config.autowrite then
vim.api.nvim_exec(
[[augroup MiniSessions
au VimLeavePre * lua if vim.v.this_session ~= '' then MiniSessions.write(nil, {force = true}) end
augroup END]],
false
)
end
end
--- Module config
---
--- Default values:
---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section)
MiniSessions.config = {
-- Whether to read latest session if Neovim opened without file arguments
autoread = false,
-- Whether to write current session before quitting Neovim
autowrite = true,
-- Directory where global sessions are stored (use `''` to disable)
--minidoc_replace_start directory = --<"session" subdir of user data directory from |stdpath()|>,
directory = ('%s%ssession'):format(vim.fn.stdpath('data'), H.path_sep),
--minidoc_replace_end
-- File for local session (use `''` to disable)
file = 'Session.vim',
-- Whether to force possibly harmful actions (meaning depends on function)
force = { read = false, write = true, delete = false },
-- Hook functions for actions. Default `nil` means 'do nothing'.
hooks = {
-- Before successful action
pre = { read = nil, write = nil, delete = nil },
-- After successful action
post = { read = nil, write = nil, delete = nil },
},
-- Whether to print session path after action
verbose = { read = false, write = true, delete = true },
}
--minidoc_afterlines_end
-- Module data ================================================================
--- Table of detected sessions. Keys represent session name. Values are tables
--- with session information that currently has these fields (but subject to
--- change):
--- - <modify_time> `(number)` modification time (see |getftime|) of session file.
--- - <name> `(string)` name of session (should be equal to table key).
--- - <path> `(string)` full path to session file.
--- - <type> `(string)` type of session ('global' or 'local').
MiniSessions.detected = {}
-- Module functionality =======================================================
--- Read detected session
---
--- What it does:
--- - Delete all current buffers with |bwipeout|. This is needed to correctly
--- restore buffers from target session. If `force` is not `true`, checks
--- beforehand for unsaved buffers and stops if there is any.
--- - Source session with supplied name.
---
---@param session_name string Name of detected session file to read. Default:
--- `nil` for default session: local (if detected) or latest session (see
--- |MiniSessions.get_latest|).
---@param opts table Table with options. Current allowed keys:
--- - <force> (whether to delete unsaved buffers; default:
--- `MiniSessions.config.force.read`).
--- - <verbose> (whether to print session path after action; default
--- `MiniSessions.config.verbose.read`).
--- - <hooks> (a table with <pre> and <post> function hooks to be executed
--- before and after successful read; overrides
--- `MiniSessions.config.hooks.pre.read` and
--- `MiniSessions.config.hooks.post.read`).
function MiniSessions.read(session_name, opts)
if H.is_disabled() then
return
end
if vim.tbl_count(MiniSessions.detected) == 0 then
H.error([[There is no detected sessions. Change configuration and rerun `MiniSessions.setup()`.]])
end
if session_name == nil then
if MiniSessions.detected[MiniSessions.config.file] ~= nil then
session_name = MiniSessions.config.file
else
session_name = MiniSessions.get_latest()
end
end
opts = vim.tbl_deep_extend('force', H.default_opts('read'), opts or {})
if not H.validate_detected(session_name) then
return
end
-- Possibly check for unsaved buffers and do nothing if they are present
if not opts.force then
local unsaved_buffers = H.get_unsaved_buffers()
if #unsaved_buffers > 0 then
local buf_list = table.concat(unsaved_buffers, ', ')
H.error(('There are unsaved buffers: %s.'):format(buf_list))
end
end
-- Execute 'pre' hook
H.possibly_execute(opts.hooks.pre)
-- Wipeout all buffers
vim.cmd([[%bwipeout!]])
-- Read session file
local session_path = MiniSessions.detected[session_name].path
vim.cmd(('source %s'):format(vim.fn.fnameescape(session_path)))
vim.v.this_session = session_path
-- Possibly notify
if opts.verbose then
H.notify(('Read session %s'):format(session_path))
end
-- Execute 'post' hook
H.possibly_execute(opts.hooks.post)
end
--- Write session
---
--- What it does:
--- - Check if file for supplied session name already exists. If it does and
--- `force` is not `true`, then stop.
--- - Write session with |mksession| to a file named `session_name`. Its
--- directory is determined based on type of session:
--- - It is at location |v:this_session| if `session_name` is `nil` and
--- there is current session.
--- - It is current working directory (|getcwd|) if `session_name` is equal
--- to `MiniSessions.config.file` (represents local session).
--- - It is `MiniSessions.config.directory` otherwise (represents global
--- session).
--- - Update |MiniSessions.detected|.
---
---@param session_name string Name of session file to write. Default: `nil` for
--- current session (|v:this_session|).
---@param opts table Table with options. Current allowed keys:
--- - <force> (whether to ignore existence of session file; default:
--- `MiniSessions.config.force.write`).
--- - <verbose> (whether to print session path after action; default
--- `MiniSessions.config.verbose.write`).
--- - <hooks> (a table with <pre> and <post> function hooks to be executed
--- before and after successful write; overrides
--- `MiniSessions.config.hooks.pre.write` and
--- `MiniSessions.config.hooks.post.write`).
function MiniSessions.write(session_name, opts)
if H.is_disabled() then
return
end
opts = vim.tbl_deep_extend('force', H.default_opts('write'), opts or {})
local session_path = H.name_to_path(session_name)
if not opts.force and H.is_readable_file(session_path) then
H.error([[Can't write to existing session when `opts.force` is not `true`.]])
end
-- Execute 'pre' hook
H.possibly_execute(opts.hooks.pre)
-- Make session file
local cmd = ('mksession%s'):format(opts.force and '!' or '')
vim.cmd(('%s %s'):format(cmd, vim.fn.fnameescape(session_path)))
-- Update detected sessions
local s = H.new_session(session_path)
MiniSessions.detected[s.name] = s
-- Possibly notify
if opts.verbose then
H.notify(('Written session %s'):format(session_path))
end
-- Execute 'post' hook
H.possibly_execute(opts.hooks.post)
end
--- Delete detected session
---
--- What it does:
--- - Check if session name is a current one. If yes and `force` is not `true`,
--- then stop.
--- - Delete session.
--- - Update |MiniSessions.detected|.
---
---@param session_name string Name of detected session file to delete. Default:
--- `nil` for name of current session (taken from |v:this_session|).
---@param opts table Table with options. Current allowed keys:
--- - <force> (whether to allow deletion of current session; default:
--- `MiniSessions.config.force.delete`).
--- - <verbose> (whether to print session path after action; default
--- `MiniSessions.config.verbose.delete`).
--- - <hooks> (a table with <pre> and <post> function hooks to be executed
--- before and after successful delete; overrides
--- `MiniSessions.config.hooks.pre.delete` and
--- `MiniSessions.config.hooks.post.delete`).
function MiniSessions.delete(session_name, opts)
if H.is_disabled() then
return
end
if vim.tbl_count(MiniSessions.detected) == 0 then
H.error([[There is no detected sessions. Change configuration and rerun `MiniSessions.setup()`.]])
end
opts = vim.tbl_deep_extend('force', H.default_opts('delete'), opts or {})
local session_path = H.name_to_path(session_name)
-- Make sure to delete only detected session (matters for local session)
session_name = vim.fn.fnamemodify(session_path, ':t')
if not H.validate_detected(session_name) then
return
end
session_path = MiniSessions.detected[session_name].path
local is_current_session = session_path == vim.v.this_session
if not opts.force and is_current_session then
H.error([[Can't delete current session when `opts.force` is not `true`.]])
end
-- Execute 'pre' hook
H.possibly_execute(opts.hooks.pre)
-- Delete and update detected sessions
vim.fn.delete(session_path)
MiniSessions.detected[session_name] = nil
if is_current_session then
vim.v.this_session = ''
end
-- Possibly notify
if opts.verbose then
H.notify(('Deleted session %s'):format(session_path))
end
-- Execute 'pre' hook
H.possibly_execute(opts.hooks.post)
end
--- Select session interactively and perform action
---
--- Note: this uses |vim.ui.select| function, which is present in Neovim
--- starting from 0.6 version. For more user-friendly experience, override it
--- (for example, with external plugins like "stevearc/dressing.nvim").
---
---@param action string Action to perform. Should be one of "read" (default),
--- "write", or "delete".
---@param opts table Options for specified action.
function MiniSessions.select(action, opts)
if not (type(vim.ui) == 'table' and type(vim.ui.select) == 'function') then
H.error('`MiniSessions.select()` requires `vim.ui.select()` function.')
end
action = action or 'read'
if not vim.tbl_contains({ 'read', 'write', 'delete' }, action) then
H.error("`action` should be one of 'read', 'write', or 'delete'.")
end
-- Ensure consistent order of items
local detected = {}
for _, session in pairs(MiniSessions.detected) do
table.insert(detected, session)
end
local sort_fun = function(a, b)
-- Put local session first, others - increasing alphabetically
local a_name = a.type == 'local' and '' or a.name
local b_name = b.type == 'local' and '' or b.name
return a_name < b_name
end
table.sort(detected, sort_fun)
local detected_names = vim.tbl_map(function(x)
return x.name
end, detected)
vim.ui.select(detected_names, {
prompt = 'Select session to ' .. action,
format_item = function(x)
return ('%s (%s)'):format(x, MiniSessions.detected[x].type)
end,
}, function(item, idx)
if item == nil then
return
end
MiniSessions[action](item, opts)
end)
end
--- Get name of latest detected session
---
--- Latest session is the session with the latest modification time determined
--- by |getftime|.
---
---@return string|nil Name of latest session or `nil` if there is no sessions.
function MiniSessions.get_latest()
if vim.tbl_count(MiniSessions.detected) == 0 then
return
end
local latest_time, latest_name = -1, nil
for name, data in pairs(MiniSessions.detected) do
if data.modify_time > latest_time then
latest_time, latest_name = data.modify_time, name
end
end
return latest_name
end
--- Act on |VimEnter|
function MiniSessions.on_vimenter()
if MiniSessions.config.autoread and not H.is_something_shown() then
MiniSessions.read()
end
end
-- Helper data ================================================================
-- Module default config
H.default_config = MiniSessions.config
-- Helper functionality =======================================================
-- Settings -------------------------------------------------------------------
function H.setup_config(config)
-- General idea: if some table elements are not present in user-supplied
-- `config`, take them from default config
vim.validate({ config = { config, 'table', true } })
config = vim.tbl_deep_extend('force', H.default_config, config or {})
-- Validate per nesting level to produce correct error message
vim.validate({
autoread = { config.autoread, 'boolean' },
autowrite = { config.autowrite, 'boolean' },
directory = { config.directory, 'string' },
file = { config.file, 'string' },
force = { config.force, 'table' },
hooks = { config.hooks, 'table' },
verbose = { config.verbose, 'table' },
})
vim.validate({
['force.read'] = { config.force.read, 'boolean' },
['force.write'] = { config.force.write, 'boolean' },
['force.delete'] = { config.force.delete, 'boolean' },
['hooks.pre'] = { config.hooks.pre, 'table' },
['hooks.post'] = { config.hooks.post, 'table' },
['verbose.read'] = { config.verbose.read, 'boolean' },
['verbose.write'] = { config.verbose.write, 'boolean' },
['verbose.delete'] = { config.verbose.delete, 'boolean' },
})
vim.validate({
['hooks.pre.read'] = { config.hooks.pre.read, 'function', true },
['hooks.pre.write'] = { config.hooks.pre.write, 'function', true },
['hooks.pre.delete'] = { config.hooks.pre.delete, 'function', true },
['hooks.post.read'] = { config.hooks.post.read, 'function', true },
['hooks.post.write'] = { config.hooks.post.write, 'function', true },
['hooks.post.delete'] = { config.hooks.post.delete, 'function', true },
})
return config
end
function H.apply_config(config)
MiniSessions.config = config
MiniSessions.detected = H.detect_sessions(config)
end
function H.is_disabled()
return vim.g.minisessions_disable == true or vim.b.minisessions_disable == true
end
-- Work with sessions ---------------------------------------------------------
function H.detect_sessions(config)
local res_global = config.directory == '' and {} or H.detect_sessions_global(config.directory)
local res_local = config.file == '' and {} or H.detect_sessions_local(config.file)
-- If there are both local and global session with same name, prefer local
return vim.tbl_deep_extend('force', res_global, res_local)
end
function H.detect_sessions_global(global_dir)
global_dir = H.full_path(global_dir)
if vim.fn.isdirectory(global_dir) ~= 1 then
H.notify(('%s is not a directory path.'):format(vim.inspect(global_dir)))
return {}
end
local globs = vim.fn.globpath(global_dir, '*')
if #globs == 0 then
return {}
end
local res = {}
for _, f in pairs(vim.split(globs, '\n')) do
if H.is_readable_file(f) then
local s = H.new_session(f, 'global')
res[s.name] = s
end
end
return res
end
function H.detect_sessions_local(local_file)
local f = H.joinpath(vim.fn.getcwd(), local_file)
if not H.is_readable_file(f) then
return {}
end
local res = {}
local s = H.new_session(f, 'local')
res[s.name] = s
return res
end
function H.new_session(session_path, session_type)
return {
modify_time = vim.fn.getftime(session_path),
name = vim.fn.fnamemodify(session_path, ':t'),
path = H.full_path(session_path),
type = session_type or H.get_session_type(session_path),
}
end
function H.get_session_type(session_path)
if MiniSessions.config.directory == '' then
return 'local'
end
local session_dir = H.full_path(session_path)
local global_dir = H.full_path(MiniSessions.config.directory)
return session_dir == global_dir and 'global' or 'local'
end
function H.validate_detected(session_name)
local is_detected = vim.tbl_contains(vim.tbl_keys(MiniSessions.detected), session_name)
if is_detected then
return true
end
H.error(('%s is not a name for detected session.'):format(vim.inspect(session_name)))
end
function H.get_unsaved_buffers()
return vim.tbl_filter(function(buf_id)
return vim.api.nvim_buf_get_option(buf_id, 'modified')
end, vim.api.nvim_list_bufs())
end
function H.get_current_session_name()
return vim.fn.fnamemodify(vim.v.this_session, ':t')
end
function H.name_to_path(session_name)
if session_name == nil then
if vim.v.this_session == '' then
H.error([[There is no active session. Supply non-nil session name.]])
end
return vim.v.this_session
end
session_name = tostring(session_name)
if session_name == '' then
H.error('Supply non-empty session name.')
end
local session_dir = (session_name == MiniSessions.config.file) and vim.fn.getcwd() or MiniSessions.config.directory
local path = H.joinpath(session_dir, session_name)
return H.full_path(path)
end
-- Utilities ------------------------------------------------------------------
function H.default_opts(action)
local config = MiniSessions.config
return {
force = config.force[action],
verbose = config.verbose[action],
hooks = { pre = config.hooks.pre[action], post = config.hooks.post[action] },
}
end
function H.notify(msg)
vim.notify(('(mini.sessions) %s'):format(msg))
end
function H.error(msg)
error(('(mini.sessions) %s'):format(msg))
end
function H.is_readable_file(path)
return vim.fn.isdirectory(path) ~= 1 and vim.fn.getfperm(path):sub(1, 1) == 'r'
end
function H.joinpath(directory, filename)
return ('%s%s%s'):format(directory, H.path_sep, tostring(filename))
end
function H.full_path(path)
return vim.fn.resolve(vim.fn.fnamemodify(path, ':p'))
end
function H.is_something_shown()
-- Don't autoread session if Neovim is opened to show something. That is
-- when at least one of the following is true:
-- - Current buffer has any lines (something opened explicitly).
-- NOTE: Usage of `line2byte(line('$') + 1) > 0` seemed to be fine, but it
-- doesn't work if some automated changed was made to buffer while leaving it
-- empty (returns 2 instead of -1). This was also the reason of not being
-- able to test with child Neovim process from 'tests/helpers'.
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, true)
if #lines > 1 or (#lines == 1 and lines[1]:len() > 0) then
return true
end
-- - Several buffers are listed (like session with placeholder buffers). That
-- means unlisted buffers (like from `nvim-tree`) don't affect decision.
local listed_buffers = vim.tbl_filter(function(buf_id)
return vim.fn.buflisted(buf_id) == 1
end, vim.api.nvim_list_bufs())
if #listed_buffers > 1 then
return true
end
-- - There are files in arguments (like `nvim foo.txt` with new file).
if vim.fn.argc() > 0 then
return true
end
return false
end
function H.possibly_execute(f)
--stylua: ignore
if f == nil then return end
return f()
end
return MiniSessions
0