diff --git a/README.md b/README.md index d3b7ad4..6beebe6 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Here are the default settings: -- Mode can be a string or a table, e.g. {"i", "n"} for both insert and normal mode delete_session = { "i", "" }, alternate_session = { "i", "" }, + copy_session = { "i", "" }, }, session_control = { @@ -176,8 +177,10 @@ You can use Telescope to see, load, and delete your sessions. It's enabled by de -- Mode can be a string or a table, e.g. {"i", "n"} for both insert and normal mode delete_session = { "i", "" }, alternate_session = { "i", "" }, + copy_session = { "i", "" }, }, -- Can also set some Telescope picker options + -- For all options, see: https://github.com/nvim-telescope/telescope.nvim/blob/master/doc/telescope.txt#L112 theme_conf = { border = true, -- layout_config = { diff --git a/doc/auto-session.txt b/doc/auto-session.txt index 2f0138c..d938594 100644 --- a/doc/auto-session.txt +++ b/doc/auto-session.txt @@ -70,6 +70,7 @@ SessionLensMappings *SessionLensMappings* Fields: ~ {delete_session} (table) mode and key for deleting a session from the picker {alternate_session} (table) mode and key for swapping to alertnate session from the picker + {copy_session} (table) mode and key for copying a session from the picker ============================================================================== diff --git a/lua/auto-session/autocmds.lua b/lua/auto-session/autocmds.lua index 80f22b2..b11a01f 100644 --- a/lua/auto-session/autocmds.lua +++ b/lua/auto-session/autocmds.lua @@ -37,39 +37,6 @@ end ---@field display_name string ---@field path string ----@return PickerItem[] -local function get_session_files() - local files = {} - local sessions_dir = M.AutoSession.get_root_dir() - - if vim.fn.isdirectory(sessions_dir) == Lib._VIM_FALSE then - return files - end - - local entries = vim.fn.readdir(sessions_dir, function(item) - return Lib.is_session_file(sessions_dir .. item) - end) - - return vim.tbl_map(function(file_name) - -- sessions_dir is guaranteed to have a trailing separator so don't need to add another one here - local session_name - local display_name - if Lib.is_legacy_file_name(file_name) then - session_name = (Lib.legacy_unescape_session_name(file_name):gsub("%.vim$", "")) - display_name = session_name .. " (legacy)" - else - session_name = Lib.escaped_session_name_to_session_name(file_name) - display_name = Lib.get_session_display_name(file_name) - end - - return { - session_name = session_name, - display_name = display_name, - path = sessions_dir .. file_name, - } - end, entries) -end - ---@param files string[] ---@param prompt string ---@param callback fun(choice: PickerItem) @@ -89,7 +56,7 @@ end ---@param data table local function handle_autosession_command(data) - local files = get_session_files() + local files = Lib.get_session_list(M.AutoSession.get_root_dir()) if data.args:match "search" then open_picker(files, "Select a session:", function(choice) M.AutoSession.autosave_and_restore(choice.session_name) @@ -105,9 +72,12 @@ end local function purge_orphaned_sessions() local orphaned_sessions = {} - for _, session in ipairs(get_session_files()) do + local session_files = Lib.get_session_list(M.AutoSession.get_root_dir()) + for _, session in ipairs(session_files) do if - not Lib.is_named_session(session.session_name) and vim.fn.isdirectory(session.session_name) == Lib._VIM_FALSE + not Lib.is_named_session(session.session_name) + -- don't want any annotations (e.g. git branch) + and vim.fn.isdirectory(session.display_name_component) == Lib._VIM_FALSE then Lib.logger.debug("purge: " .. session.session_name) table.insert(orphaned_sessions, session.session_name) diff --git a/lua/auto-session/config.lua b/lua/auto-session/config.lua index 3973609..35c2f75 100644 --- a/lua/auto-session/config.lua +++ b/lua/auto-session/config.lua @@ -57,6 +57,7 @@ local M = {} ---@class SessionLensMappings ---@field delete_session table mode and key for deleting a session from the picker ---@field alternate_session table mode and key for swapping to alertnate session from the picker +---@field copy_session table mode and key for copying a session from the picker ---@type AutoSession.Config local defaults = { @@ -94,6 +95,7 @@ local defaults = { -- Mode can be a string or a table, e.g. {"i", "n"} for both insert and normal mode delete_session = { "i", "" }, alternate_session = { "i", "" }, + copy_session = { "i", "" }, }, ---@type SessionControl @@ -183,6 +185,7 @@ local function check_old_config_names(config) and type(config["cwd_change_handling"]) == "table" and config.cwd_change_handling["restore_upcoming_session"] then + M.has_old_config = true local old_cwd_change_handling = config.cwd_change_handling or {} -- shouldn't be nil but placate LS config["cwd_change_handling"] = old_cwd_change_handling.restore_upcoming_session if old_cwd_change_handling["pre_cwd_changed_hook"] then @@ -192,6 +195,13 @@ local function check_old_config_names(config) config.post_cwd_changed_cmds = { old_cwd_change_handling.post_cwd_changed_hook } end end + + if config.session_lens and config.session_lens.shorten_path ~= nil then + M.has_old_config = true + if config.session_lens.shorten_path then + config.session_lens.path_display = { "shorten" } + end + end end ---@param config? AutoSession.Config diff --git a/lua/auto-session/init.lua b/lua/auto-session/init.lua index a792235..b7402be 100644 --- a/lua/auto-session/init.lua +++ b/lua/auto-session/init.lua @@ -321,7 +321,7 @@ end ---@private ---Gets the root directory of where to save the sessions. ---By default this resolves to `vim.fn.stdpath "data" .. "/sessions/"` ----@param with_trailing_separator? boolean whether to incude the trailing separator. A few places (telescope picker don't expect a trailing separator) (Defaults to true) +---@param with_trailing_separator? boolean whether to incude the trailing separator. A few places (e.g. telescope picker) don't expect a trailing separator (Defaults to true) ---@return string function AutoSession.get_root_dir(with_trailing_separator) if with_trailing_separator == nil then diff --git a/lua/auto-session/lib.lua b/lua/auto-session/lib.lua index 34edaa5..a49a3df 100644 --- a/lua/auto-session/lib.lua +++ b/lua/auto-session/lib.lua @@ -604,4 +604,81 @@ function Lib.flatten_table_and_split_strings(input) return output end +---Returns the list of files in a directory, sorted by modification time +---@param dir string the directory to list +---@return table The filenames, sorted by modification time +function Lib.sorted_readdir(dir) + -- Get list of files + local files = vim.fn.readdir(dir) + + -- Create a table with file names and modification times + local file_times = {} + for _, file in ipairs(files) do + local full_path = dir .. "/" .. file + local mod_time = vim.fn.getftime(full_path) + table.insert(file_times, { name = file, time = mod_time }) + end + + -- Sort the table based on modification times (most recent first) + table.sort(file_times, function(a, b) + return a.time > b.time + end) + + -- Extract just the file names from the sorted table + local sorted_files = {} + for _, file in ipairs(file_times) do + table.insert(sorted_files, file.name) + end + + return sorted_files +end + +---Get the list of session files. Will filter out any extra command session files +---@param sessions_dir string The directory where the sessions are stored +---@return table the list of session files +function Lib.get_session_list(sessions_dir) + if vim.fn.isdirectory(sessions_dir) == Lib._VIM_FALSE then + return {} + end + + local entries = Lib.sorted_readdir(sessions_dir) + + return vim.tbl_map(function(file_name) + local session_name + local display_name_component + + if not Lib.is_session_file(sessions_dir .. file_name) then + return nil + end + + -- an annotation about the session, added to display_name after any path processing + local annotation = "" + if Lib.is_legacy_file_name(file_name) then + session_name = (Lib.legacy_unescape_session_name(file_name):gsub("%.vim$", "")) + display_name_component = session_name + annotation = " (legacy)" + else + session_name = Lib.escaped_session_name_to_session_name(file_name) + display_name_component = session_name + local name_components = Lib.get_session_display_name_as_table(file_name) + if #name_components > 1 then + display_name_component = name_components[1] + annotation = " " .. name_components[2] + end + end + + local display_name = display_name_component .. annotation + + return { + session_name = session_name, + -- include the components in case telescope wants to shorten the path + display_name_component = display_name_component, + annotation_component = annotation, + display_name = display_name, + file_name = file_name, + path = sessions_dir .. file_name, + } + end, entries) +end + return Lib diff --git a/lua/auto-session/session-lens/actions.lua b/lua/auto-session/session-lens/actions.lua index cb3094b..77cca2e 100644 --- a/lua/auto-session/session-lens/actions.lua +++ b/lua/auto-session/session-lens/actions.lua @@ -1,6 +1,7 @@ local AutoSession = require "auto-session" local Config = require "auto-session.config" local Lib = require "auto-session.lib" +local transform_mod = require("telescope.actions.mt").transform_mod local M = {} @@ -66,7 +67,7 @@ M.delete_session = function(prompt_bufnr) local current_picker = action_state.get_current_picker(prompt_bufnr) current_picker:delete_selection(function(selection) if selection then - AutoSession.DeleteSessionFile(selection.path, selection.display) + AutoSession.DeleteSessionFile(selection.path, selection.display()) end end) end @@ -92,4 +93,21 @@ M.alternate_session = function(prompt_bufnr) source_session(session_name, prompt_bufnr) end -return M +---@private +---Copy session action +---Ask user for the new name and then copy the session to that name +M.copy_session = function(_) + local action_state = require "telescope.actions.state" + local selection = action_state.get_selected_entry() + + local new_name = vim.fn.input("New session name: ", selection.session_name) + + if not new_name or new_name == "" then + return + end + + local content = vim.fn.readfile(selection.path) + vim.fn.writefile(content, AutoSession.get_root_dir() .. Lib.escape_session_name(new_name) .. ".vim") +end + +return transform_mod(M) diff --git a/lua/auto-session/session-lens/init.lua b/lua/auto-session/session-lens/init.lua index 3e69ace..dec1b8e 100644 --- a/lua/auto-session/session-lens/init.lua +++ b/lua/auto-session/session-lens/init.lua @@ -7,135 +7,112 @@ local AutoSession = require "auto-session" local SessionLens = {} ---@private ----Function generator that returns the function for generating telescope file entries. Only exported ----for testing. ----@param opts table Options for how paths sould be displayed. Only supports opts.shorten ----@return function The function to be set as entry_maker in Telescope picker options -function SessionLens.make_telescope_callback(opts) - local session_root_dir = AutoSession.get_root_dir() +---Search session +---Triggers the customized telescope picker for switching sessions +---@param custom_opts table +SessionLens.search_session = function(custom_opts) + local telescope_themes = require "telescope.themes" + local telescope_actions = require "telescope.actions" + local telescope_finders = require "telescope.finders" + local telescope_conf = require("telescope.config").values - -- just used for shortening the display_name (if enabled) - local path = require "plenary.path" - return function(file_name) - -- Don't include x.vim files that nvim makes for custom user - -- commands - if not Lib.is_session_file(session_root_dir .. file_name) then - return nil - end - - -- the name of the session, to be used for restoring/deleting - local session_name - - -- the name to display, possibly with a shortened path - local display_name - - -- an annotation about the session, added to display_name after any path processing - local annotation = "" - if Lib.is_legacy_file_name(file_name) then - session_name = (Lib.legacy_unescape_session_name(file_name):gsub("%.vim$", "")) - display_name = session_name - annotation = " (legacy)" - else - session_name = Lib.escaped_session_name_to_session_name(file_name) - display_name = session_name - local name_components = Lib.get_session_display_name_as_table(file_name) - if #name_components > 1 then - display_name = name_components[1] - annotation = " " .. name_components[2] - end - end + -- use custom_opts if specified and non-empty. Otherwise use the config + if not custom_opts or vim.tbl_isempty(custom_opts) then + custom_opts = Config.session_lens + end + custom_opts = custom_opts or {} - if opts.path_display and vim.tbl_contains(opts.path_display, "shorten") then - display_name = path:new(display_name):shorten() - if not display_name then - display_name = session_name - end - end - display_name = display_name .. annotation + -- get the theme defaults, with any overrides in custom_opts.theme_conf + local theme_opts = telescope_themes.get_dropdown(custom_opts.theme_conf) - return { - ordinal = session_name, - value = session_name, - filename = file_name, - cwd = session_root_dir, - display = display_name, - path = session_root_dir .. file_name, - } + -- path_display could've been in theme_conf but that's not where we put it + if custom_opts.path_display then + -- copy over to the theme options + theme_opts.path_display = custom_opts.path_display end -end ----@private ----Search session ----Triggers the customized telescope picker for switching sessions ----@param custom_opts any -SessionLens.search_session = function(custom_opts) - local themes = require "telescope.themes" - local telescope_actions = require "telescope.actions" + if theme_opts.path_display then + -- If there's a path_display setting, we have to force path_display.absolute = true here, + -- otherwise the session for the cwd will be displayed as just a dot + theme_opts.path_display.absolute = true + end - custom_opts = (vim.tbl_isempty(custom_opts or {}) or custom_opts == nil) and Config.session_lens or custom_opts + theme_opts.previewer = custom_opts.previewer - -- Use auto_session_root_dir from the Auto Session plugin local session_root_dir = AutoSession.get_root_dir() - if custom_opts.shorten_path ~= nil then - Lib.logger.warn "`shorten_path` config is deprecated, use the new `path_display` config instead" - if custom_opts.shorten_path then - custom_opts.path_display = { "shorten" } - else - custom_opts.path_display = nil - end + local session_entry_maker = function(session_entry) + return { - custom_opts.shorten_path = nil - end + ordinal = session_entry.session_name, + value = session_entry.session_name, + session_name = session_entry.session_name, + filename = session_entry.file_name, + path = session_entry.path, + cwd = session_root_dir, + + -- We can't calculate the vaue of display until the picker is acutally displayed + -- because telescope.utils.transform_path may depend on the window size, + -- specifically with the truncate option. So we use a function that will be + -- called when actually displaying the row + display = function(_) + if session_entry.already_set_display_name then + return session_entry.display_name + end + + session_entry.already_set_display_name = true + + if not theme_opts or not theme_opts.path_display then + return session_entry.display_name + end - local theme_opts = themes.get_dropdown(custom_opts.theme_conf) + local telescope_utils = require "telescope.utils" - -- Use default previewer config by setting the value to nil if some sets previewer to true in the custom config. - -- Passing in the boolean value errors out in the telescope code with the picker trying to index a boolean instead of a table. - -- This fixes it but also allows for someone to pass in a table with the actual preview configs if they want to. - if custom_opts.previewer ~= false and custom_opts.previewer == true then - custom_opts["previewer"] = nil + return telescope_utils.transform_path(theme_opts, session_entry.display_name_component) + .. session_entry.annotation_component + end, + } + end + + local finder_maker = function() + return telescope_finders.new_table { + results = Lib.get_session_list(session_root_dir), + entry_maker = session_entry_maker, + } end local opts = { prompt_title = "Sessions", - entry_maker = SessionLens.make_telescope_callback(custom_opts), - cwd = session_root_dir, - attach_mappings = function(_, map) + attach_mappings = function(prompt_bufnr, map) telescope_actions.select_default:replace(Actions.source_session) local mappings = Config.session_lens.mappings if mappings then map(mappings.delete_session[1], mappings.delete_session[2], Actions.delete_session) map(mappings.alternate_session[1], mappings.alternate_session[2], Actions.alternate_session) + + Actions.copy_session:enhance { + post = function() + local action_state = require "telescope.actions.state" + local picker = action_state.get_current_picker(prompt_bufnr) + picker:refresh(finder_maker(), { reset_prompt = true }) + end, + } + + map(mappings.copy_session[1], mappings.copy_session[2], Actions.copy_session) end return true end, } - opts = vim.tbl_deep_extend("force", opts, theme_opts, custom_opts or {}) - local find_command = (function() - if opts.find_command then - if type(opts.find_command) == "function" then - return opts.find_command(opts) - end - return opts.find_command - elseif 1 == vim.fn.executable "rg" then - return { "rg", "--files", "--color", "never", "--sortr", "modified" } - elseif 1 == vim.fn.executable "ls" then - return { "ls", "-t" } - elseif 1 == vim.fn.executable "cmd" and vim.fn.has "win32" == 1 then - return { "cmd", "/C", "dir", "/b", "/o-d" } - end - end)() - - local finders = require "telescope.finders" - local conf = require("telescope.config").values + -- add the theme options + opts = vim.tbl_deep_extend("force", opts, theme_opts) + require("telescope.pickers") .new(opts, { - finder = finders.new_oneshot_job(find_command, opts), - previewer = conf.grep_previewer(opts), - sorter = conf.file_sorter(opts), + finder = finder_maker(), + previewer = telescope_conf.file_previewer(opts), + sorter = telescope_conf.file_sorter(opts), }) :find() end diff --git a/tests/cmds_spec.lua b/tests/cmds_spec.lua index 48b7f54..4f192f3 100644 --- a/tests/cmds_spec.lua +++ b/tests/cmds_spec.lua @@ -34,6 +34,13 @@ describe("The default config", function() -- Make sure there isn't an extra commands file by default local default_extra_cmds_path = TL.default_session_path:gsub("%.vim$", "x.vim") assert.equals(0, vim.fn.filereadable(default_extra_cmds_path)) + + local sessions = Lib.get_session_list(as.get_root_dir()) + assert.equal(1, #sessions) + + assert.equal(TL.session_dir .. sessions[1].file_name, TL.default_session_path) + assert.equal(sessions[1].display_name, Lib.current_session_name()) + assert.equal(sessions[1].session_name, TL.default_session_name) end) it("can restore a session for the cwd", function() diff --git a/tests/git_spec.lua b/tests/git_spec.lua index 992b96b..abf8ea8 100644 --- a/tests/git_spec.lua +++ b/tests/git_spec.lua @@ -62,6 +62,13 @@ describe("The git config", function() assert.equals(1, vim.fn.filereadable(branch_session_path)) assert.equals(vim.fn.getcwd() .. " (branch: main)", Lib.current_session_name()) + + local sessions = Lib.get_session_list(as.get_root_dir()) + assert.equal(1, #sessions) + + assert.equal(TL.session_dir .. sessions[1].file_name, branch_session_path) + assert.equal(sessions[1].display_name, Lib.current_session_name()) + assert.equal(sessions[1].session_name, vim.fn.getcwd() .. "|main") end) it("Autorestores a session with the branch name", function() @@ -108,6 +115,5 @@ describe("The git config", function() assert.equals(1, vim.fn.filereadable(session_path)) assert.equals(vim.fn.getcwd() .. " (branch: slash/branch)", Lib.current_session_name()) assert.equals(git_test_dir .. " (branch: slash/branch)", Lib.current_session_name(true)) - print(Lib.current_session_name()) end) end) diff --git a/tests/mini-tests/test_ui.lua b/tests/mini-tests/test_ui.lua index 4fe69a4..622a082 100644 --- a/tests/mini-tests/test_ui.lua +++ b/tests/mini-tests/test_ui.lua @@ -63,6 +63,25 @@ T["session lens"]["can load a session"] = function() expect.equality(1, child.fn.bufexists(TL.other_file)) end +T["session lens"]["can copy a session"] = function() + expect.equality(0, child.fn.bufexists(TL.test_file)) + child.cmd "SessionSearch" + -- give the UI time to come up + local session_name = "project_x" + vim.loop.sleep(250) + child.type_keys(session_name) + vim.loop.sleep(20) + child.type_keys "" + vim.loop.sleep(20) + + -- will append to session_name + local copy_name = "copy" + child.type_keys(copy_name .. "") + -- give the session time to load + vim.loop.sleep(500) + expect.equality(1, vim.fn.filereadable(TL.makeSessionPath(session_name .. copy_name))) +end + T["session lens"]["can delete a session"] = function() expect.equality(1, vim.fn.filereadable(TL.named_session_path)) child.cmd "SessionSearch" diff --git a/tests/session_lens_spec.lua b/tests/session_lens_spec.lua deleted file mode 100644 index 9ecacee..0000000 --- a/tests/session_lens_spec.lua +++ /dev/null @@ -1,24 +0,0 @@ ----@diagnostic disable: undefined-field -local TL = require "tests/test_lib" - -describe("Session lens", function() - local as = require "auto-session" - local session_lens = require "auto-session.session-lens" - as.setup { - -- log_level = "debug", - } - - it("can get the session files", function() - as.SaveSession() - as.SaveSession "project_x" - - local make_telescope_entry = session_lens.make_telescope_callback {} - - local data = make_telescope_entry(TL.escapeSessionName(TL.default_session_name) .. ".vim") - assert.not_nil(data) - - data = make_telescope_entry "project_x.vim" - assert.not_nil(data) - -- - end) -end)