From 2d51ce254e3bffeca0d3491e4c53e1324346c2e9 Mon Sep 17 00:00:00 2001 From: Joshua Wood Date: Wed, 26 Jun 2024 07:57:21 +1200 Subject: [PATCH] feat!: improved autoloading by cwd (#63) This extends the concept of "loading cwd sessions" by also considering any named session that has the same cwd as the one returned by getcwd() - previously only session with the name matching cwd would be loaded (so the one saved by PossessionSaveCwd. There are different strategies for autoloading on VimEnter controlled by the "autoload" setup option (last/auto_cwd/last_cwd). This is a breaking change because PossessionLoadCwd now works differently - loads newest session with data.cwd matching getcwd(). It takes optional argument with autocomplete of sessions matching cwd. --- README.md | 8 +-- doc/possession.txt | 40 ++++++++++----- lua/possession.lua | 8 ++- lua/possession/commands.lua | 94 +++++++++++++++++++++++++++++++----- lua/possession/config.lua | 15 ++++-- lua/possession/display.lua | 4 ++ lua/possession/paths.lua | 9 ++++ lua/possession/query.lua | 11 ++++- lua/possession/session.lua | 34 +++++++------ lua/possession/telescope.lua | 6 +++ plugin/possession.lua | 28 ++++++++--- 11 files changed, 201 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index d3343c7..6c627b3 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,7 @@ require('possession').setup { on_load = true, on_quit = true, }, - autoload = { - cwd = false, -- or fun(): boolean - }, + autoload = false, -- or 'last' or 'auto_cwd' or 'last_cwd' or fun(): string commands = { save = 'PossessionSave', load = 'PossessionLoad', @@ -89,6 +87,7 @@ require('possession').setup { delete = 'PossessionDelete', show = 'PossessionShow', list = 'PossessionList', + list_cwd = 'PossessionListCwd', migrate = 'PossessionMigrate', }, hooks = { @@ -192,6 +191,9 @@ require('telescope').load_extension('possession') Then use `:Telescope possession list` or `require('telescope').extensions.possession.list()` The default action will load selected session. +Alternatively, use `:Telescope possession list only_cwd=true` or `require('telescope').extensions.possession.list({only_cwd=true})` +This will limit the displayed sessions to those related to the current working directory. + ![telescope](./img/telescope.png) ## Auto-save diff --git a/doc/possession.txt b/doc/possession.txt index c521bc2..bfb6223 100644 --- a/doc/possession.txt +++ b/doc/possession.txt @@ -11,31 +11,32 @@ currently loaded session is used (unless noted otherwise). Save the current session information under `session_dir`. Use `:PossessionSave! [{name}]` to avoid asking for confirmation when overwriting -existing session file. If [{name}] is not provided and a session does not +existing session file. If [{name}] is omitted and a session does not already exist, you will be prompted for a name. *:PossessionLoad* :PossessionLoad [{name}]~ -Load given session from disk. When `name` is not ommitted, then use last +Load given session from disk. If `name` is omitted then use last loaded session name. *:PossessionSaveCwd* :PossessionSaveCwd Save session for current working directory. Use `!` to avoid confirmation -dialog. +dialog. The session name will be the current working directory. *:PossessionLoadCwd* -:PossessionLoadCwd +:PossessionLoadCwd [{name}] -Load session for current working directory. +Load given session for current working directory. If `name` is omitted +then use last session name for the current working directory. *:PossessionRename* :PossessionRename [{name} [{new_name}]]~ -Rename session `name` to `new_name`. If `new_name` is ommited then it is taken -from |vim.ui.input|. If `name` is ommited then current session name is used. +Rename session `name` to `new_name`. If `new_name` is omitted then it is taken +from |vim.ui.input|. If `name` is omitted then current session name is used. *:PossessionClose* :PossessionClose~ @@ -59,6 +60,14 @@ Show given session info. List available sessions. `:PossessionList!` will not hide the `vimscript` field that contains commands generated by |:mksession|. + +:PossessionListCwd [{dir}]~ + +List available sessions with a cwd that matches the specified `dir`. +If `dir` is omitted the current working directory is used. +`:PossessionListCwd!` will not hide the `vimscript` field that contains +commands generated by |:mksession|. + *:PossessionMigrate* :PossessionMigrate {dir_or_file}~ @@ -146,10 +155,19 @@ autosave.on_quit~ way the current window layout will be preserved. *possession-autoload* -autoload.cwd~ - `boolean | function(): boolean` - Automatically load a session for current working directory (created by - `autosave.cwd`) on VimEnter if such a session exists. +autoload~ + `string | function(): string + Automatically load a session. Valid string values are: + - `last` loads the last saved session + - `auto_cwd` loads the session saved by `autosave.cwd` or `:PossessionSaveCwd` + - `last_cwd` loads the last session for the current working directory + + If a function is provided, it needs to return the name of a session file + or a directory. If it is a directory, the last session for that directory + is loaded. The function can also return one of the above strings, in + which case the behaviour is as defined for that string. + If any files or folders are passed as command line arguments to Neovim, + autoload is always skipped. *possession-hooks* hooks.before_save~ diff --git a/lua/possession.lua b/lua/possession.lua index 9be5026..26f2a49 100644 --- a/lua/possession.lua +++ b/lua/possession.lua @@ -13,6 +13,7 @@ local function setup(opts) local names = config.commands local commands = require('possession.commands') local complete = commands.complete_session + local cwd_complete = commands.cwd_complete_session cmd(names.save, 'name?', { nargs = '?', complete = complete, bang = true }, function(o) commands.save(o.fargs[1], o.bang) @@ -23,8 +24,8 @@ local function setup(opts) cmd(names.save_cwd, '', { nargs = 0, bang = true }, function(o) commands.save_cwd(o.bang) end) - cmd(names.load_cwd, '', { nargs = 0 }, function(o) - commands.load_cwd() + cmd(names.load_cwd, 'name?', { nargs = '?', complete = cwd_complete }, function(o) + commands.load_cwd(o.fargs[1]) end) cmd(names.rename, 'old_name? new_name?', { nargs = '*', complete = complete }, function(o) commands.rename(o.fargs[1], o.fargs[2]) @@ -41,6 +42,9 @@ local function setup(opts) cmd(names.list, '', { nargs = 0, bang = true }, function(o) commands.list(o.bang) end) + cmd(names.list_cwd, 'dir?', { nargs = '?', complete = 'dir', bang = true }, function(o) + commands.list_cwd(o.fargs[1], o.bang) + end) cmd(names.migrate, 'dir_or_file', { nargs = 1, complete = 'file' }, function(o) commands.migrate(o.fargs[1]) end) diff --git a/lua/possession/commands.lua b/lua/possession/commands.lua index 84fb42a..d418fe1 100644 --- a/lua/possession/commands.lua +++ b/lua/possession/commands.lua @@ -35,8 +35,8 @@ local function complete_list(candidates, opts) end end --- Limits filesystem access by caching the session names per command line access ----@type table? +-- Limits filesystem access by caching the session data per command line access +---@type table? local cached_names vim.api.nvim_create_autocmd('CmdlineLeave', { group = vim.api.nvim_create_augroup('possession.commands.complete', { clear = true }), @@ -49,13 +49,27 @@ local function get_session_names() if not cached_names then cached_names = {} for file, data in pairs(session.list()) do - cached_names[file] = data.name + cached_names[file] = { name = data.name, cwd = data.cwd } end end return cached_names end -M.complete_session = complete_list(get_session_names) +M.complete_session = complete_list(function() + return vim.tbl_map(function(s) + return s.name + end, get_session_names()) +end) + +M.cwd_complete_session = complete_list(function() + local cwd = vim.fn.getcwd() + local cwd_sessions = vim.tbl_filter(function(s) + return s.cwd == cwd + end, get_session_names()) + return vim.tbl_map(function(s) + return s.name + end, cwd_sessions) +end) local function get_current() local name = session.get_session_name() @@ -66,15 +80,17 @@ local function get_current() return name end -local function get_last() - local sessions = query.as_list() +---@param dir string dir to get sessions for +local function get_sessions_for_dir(dir) + return query.filter_by(query.as_list(), { cwd = paths.absolute_dir(dir) }) +end + +---@param sessions? table[] list of sessions from `as_list` +local function get_last(sessions) + sessions = sessions or query.as_list() query.sort_by(sessions, 'mtime', true) local last_session = sessions and sessions[1] - if not last_session then - utils.error('Cannot find last loaded session - specify session name as an argument') - return nil - end - return last_session.name + return last_session and last_session.name end local function name_or(name, getter) @@ -100,6 +116,8 @@ function M.load(name) name = name_or(name, get_last) if name then session.load(name) + else + utils.error('Cannot find last loaded session - specify session name as an argument') end end @@ -108,8 +126,52 @@ function M.save_cwd(no_confirm) session.save(paths.cwd_session_name(), { no_confirm = no_confirm }) end -function M.load_cwd() - session.load(paths.cwd_session_name()) +---@param name? string +function M.load_cwd(name) + local last = function() + return get_last(get_sessions_for_dir(vim.fn.getcwd())) + end + + name = name_or(name, last) + if name then + session.load(name) + else + utils.error('Cannot find last loaded cwd session - specify session name as an argument') + end +end + +---@param session_type string +function M.load_last(session_type) + local last + if session_type == 'last' then + last = get_last() + elseif session_type == 'auto_cwd' then + last = paths.cwd_session_name() + elseif session_type == 'last_cwd' then + last = get_last(get_sessions_for_dir(vim.fn.getcwd())) + elseif session_type then + -- Something was returned from custom config function. + if vim.fn.isdirectory(vim.fn.fnamemodify(session_type, ':p')) == 1 then + local abs = paths.absolute_dir(session_type) + last = get_last(get_sessions_for_dir(abs)) + else + -- Try to load returned string as literal session name. + + -- Futher down the `session.load` call stack will error + -- if `session_type` ends with `.json`. Strip if off, it + -- will get added back when needed. + last = string.gsub(session_type, '.json$', '') + end + else + utils.error('Possession.nvim: Unknown `autoload` config value `' .. session_type .. '`') + return + end + + if last then + session.load(last, { skip_autosave = true }) + return last + end + utils.info('No session found to autoload') end local function maybe_input(value, opts, callback) @@ -174,6 +236,12 @@ function M.list(full) display.echo_sessions { vimscript = full } end +---@param full? boolean +function M.list_cwd(dir, full) + dir = dir or vim.fn.getcwd() + display.echo_sessions { vimscript = full, sessions = get_sessions_for_dir(dir) } +end + ---@param path string function M.migrate(path) if vim.fn.getftype(path) == 'file' then diff --git a/lua/possession/config.lua b/lua/possession/config.lua index 4ab5c22..17a2b48 100644 --- a/lua/possession/config.lua +++ b/lua/possession/config.lua @@ -22,9 +22,7 @@ local function defaults() on_load = true, on_quit = true, }, - autoload = { - cwd = false, -- or fun(): boolean - }, + autoload = false, -- or 'last' or 'auto_cwd' or 'last_cwd' or fun(): string commands = { save = 'PossessionSave', load = 'PossessionLoad', @@ -35,6 +33,7 @@ local function defaults() delete = 'PossessionDelete', show = 'PossessionShow', list = 'PossessionList', + list_cwd = 'PossessionListCwd', migrate = 'PossessionMigrate', }, hooks = { @@ -151,13 +150,19 @@ local function fix_compatibility(opts) enable = opts.telescope.previewer, } end + + local autoload = vim.tbl_get(opts, 'autoload') + if type(autoload) == 'table' then + vim.deprecate('`setup.autoload.cwd = true`', '`autoload = "..."`', 'in the future', 'possession') + opts.autoload = autoload.cwd and 'auto_cwd' or false + end end function M.setup(opts) - warn_on_unknown_keys(opts) - fix_compatibility(opts) + warn_on_unknown_keys(opts) + local new_config = vim.tbl_deep_extend('force', {}, defaults(), opts or {}) -- Do _not_ replace the table pointer with `config = ...` because this -- wouldn't change the tables that have already been `require`d by other diff --git a/lua/possession/display.lua b/lua/possession/display.lua index 38f656f..9c0216e 100644 --- a/lua/possession/display.lua +++ b/lua/possession/display.lua @@ -110,6 +110,10 @@ function M.echo_sessions(opts) }, opts or {}) local sessions = opts.sessions or query.as_list() + if #sessions == 0 then + utils.info('No sessions found') + return + end local info = {} if opts.buffers or opts.tab_cwd then diff --git a/lua/possession/paths.lua b/lua/possession/paths.lua index 32b9b00..8124b59 100644 --- a/lua/possession/paths.lua +++ b/lua/possession/paths.lua @@ -32,4 +32,13 @@ function M.cwd_session_name() return vim.fn.fnamemodify(global_cwd, ':~') end +--- Vim expands the given dir, then converts it to an absolute path +function M.absolute_dir(dir) + local p = Path:new(vim.fn.expand(dir)):absolute() + if vim.endswith(p, Path.path.sep) then + p = p:sub(1, #p - 1) + end + return p +end + return M diff --git a/lua/possession/query.lua b/lua/possession/query.lua index da3050e..cbb7970 100644 --- a/lua/possession/query.lua +++ b/lua/possession/query.lua @@ -8,7 +8,7 @@ local config = require('possession.config') ---@param sessions? table like from possession.session.list() ---@return table[] list of session data with additional `file` key function M.as_list(sessions) - sessions = sessions or session.list() --[[@as table ]] + sessions = sessions or session.list() local list = {} for file, data in pairs(sessions) do if data.file then @@ -21,6 +21,15 @@ function M.as_list(sessions) return list end +--- Filters a list of sessions +---@param sessions table[] list of sessions from `as_list` +---@param opts { cwd: string } +function M.filter_by(sessions, opts) + return vim.tbl_filter(function(s) + return s.cwd == opts.cwd + end, sessions) +end + ---@alias possession.QuerySortKey 'name'|'atime'|'mtime'|'ctime' --- Sort a list of sessions in-place diff --git a/lua/possession/session.lua b/lua/possession/session.lua index 8b72439..2bfd288 100644 --- a/lua/possession/session.lua +++ b/lua/possession/session.lua @@ -95,9 +95,9 @@ function M.save(name, opts) state.session_name = name - utils.info('Saved as "%s"', short) + utils.info('Saved session as "%s"', short) else - utils.info('Aborting save') + utils.info('Aborting session save') end if not opts.vimscript then @@ -123,7 +123,6 @@ end ---@param new_name string new name to use function M.rename(old_name, new_name) vim.validate { - old_name = { old_name, 'string' }, new_name = { new_name, 'string' }, } @@ -178,10 +177,10 @@ function M.autosave_info() end end -function M.autosave() - local info = M.autosave_info() +function M.autosave(autosave_info) + local info = autosave_info or M.autosave_info() if info then - utils.debug('Auto-saving %s session "%s"', info.variant, state.session_name) + utils.debug('Auto-saving %s session "%s"', info.variant, info.name) M.save(info.name, { no_confirm = true }) end end @@ -219,9 +218,10 @@ local function restore_global_options(options) end --- Load session by name (or from raw data) ---- ---@param name_or_data string|table name or raw data that will be saved as the session file in JSON format -function M.load(name_or_data) +---@param opts? { skip_autosave?: boolean } +function M.load(name_or_data, opts) + opts = opts or { skip_autosave = false } vim.validate { name_or_data = { name_or_data, utils.is_type { 'string', 'table' } } } -- Load session data @@ -229,15 +229,21 @@ function M.load(name_or_data) local path if type(name_or_data) == 'string' then path = paths.session(name_or_data) + if not path:exists() then + utils.error('Cannot load session "%s" - it does not exist', name_or_data) + return + end session_data = vim.json.decode(path:read()) else session_data = name_or_data end -- Autosave if not loading the auto-saved session itself - local autosave_info = M.autosave_info() - if config.autosave.on_load and (autosave_info and session_data.name ~= M.autosave_info().name) then - M.autosave() + if not opts.skip_autosave then + local autosave_info = M.autosave_info() + if config.autosave.on_load and (autosave_info and session_data.name ~= autosave_info.name) then + M.autosave(autosave_info) + end end -- Run pre-load hook that can pre-process user data, abort if returns falsy value. @@ -311,7 +317,7 @@ function M.delete(name, opts) local short = paths.session_short(name) if not path:exists() then - utils.warn('Session not exists: "%s"', path:absolute()) + utils.warn('Cannot delete session "%s" - it does not exist', path:absolute()) return end @@ -323,10 +329,10 @@ function M.delete(name, opts) if state.session_name == name then state.session_name = nil end - utils.info('Deleted "%s"', short) + utils.info('Deleted session "%s"', short) end else - utils.info('Aborting delete') + utils.info('Aborting session delete') end if opts.callback then diff --git a/lua/possession/telescope.lua b/lua/possession/telescope.lua index f8d348f..08ded73 100644 --- a/lua/possession/telescope.lua +++ b/lua/possession/telescope.lua @@ -64,6 +64,7 @@ local session_actions = { ---@field default_action? 'load'|'save'|'delete' ---@field sessions? table[] list of sessions like returned by query.as_list ---@field sort? boolean|possession.QuerySortKey sort the initial sessions list, `true` means 'mtime' +---@field only_cwd? boolean only display sessions for the cwd ---@param opts possession.TelescopeListOpts function M.list(opts) @@ -71,6 +72,7 @@ function M.list(opts) default_action = 'load', sessions = nil, sort = 'mtime', + only_cwd = false, }, opts or {}) assert( @@ -80,6 +82,10 @@ function M.list(opts) local get_finder = function() local sessions = opts.sessions and vim.list_slice(opts.sessions) or query.as_list() + if opts.only_cwd then + sessions = query.filter_by(sessions, { cwd = vim.fn.getcwd() }) + end + if opts.sort then local key = opts.sort == true and 'name' or opts.sort local descending = key ~= 'name' diff --git a/plugin/possession.lua b/plugin/possession.lua index 72b91b6..042b720 100644 --- a/plugin/possession.lua +++ b/plugin/possession.lua @@ -1,4 +1,12 @@ local group = vim.api.nvim_create_augroup('Possession', {}) +local nvim_received_stdin = false + +vim.api.nvim_create_autocmd({ 'StdinReadPre' }, { + group = group, + callback = function() + nvim_received_stdin = true + end, +}) vim.api.nvim_create_autocmd({ 'VimLeavePre' }, { group = group, @@ -13,23 +21,29 @@ vim.api.nvim_create_autocmd('VimEnter', { group = group, nested = true, -- to correctly setup buffers callback = function() + -- vim.cmd "clearjumps" -- Be lazy when loading modules local config = require('possession.config') - -- Delete old symlink that is not used anymore + -- Delete old symlink that is not used any more -- TODO: remove when we explicitly drop support for nvim <0.10 which does not have vim.fs.joinpath if vim.tbl_get(vim, 'fs', 'joinpath') then local symlink = vim.fs.joinpath(config.session_dir, '__last__') vim.fn.delete(symlink) end + if vim.fn.argc() > 0 or nvim_received_stdin then + -- Skip autoload if any files or folders are passed as command line arguments. + return + end + local utils = require('possession.utils') - if utils.as_function(config.autoload.cwd)() then - local paths = require('possession.paths') - local cwd = paths.cwd_session_name() - if paths.session(cwd):exists() then - utils.debug('Auto-loading CWD session: %s', cwd) - require('possession.session').load(cwd) + local al = utils.as_function(config.autoload)() + if al and al ~= '' then + local cmd = require('possession.commands') + local session = cmd.load_last(al) + if session then + utils.debug('Auto-loading session: %s', session) end end end,