diff --git a/lua/persistence/actions.lua b/lua/persistence/actions.lua new file mode 100644 index 0000000..8b60f13 --- /dev/null +++ b/lua/persistence/actions.lua @@ -0,0 +1,78 @@ +local Popup = require("persistence.popup") + +local M = {} + +local state = {} +M.state = state + +---@param win integer window id +---@return string +local function get_current_char(win) + local current_pos = vim.api.nvim_win_get_cursor(win) + local current_line = vim.api.nvim_get_current_line() + local current_char = current_line:match("(.)", current_pos[2] + 1) + + return current_char +end + +---Close the popup window according to `close_callback` if defined, otherwise bluntly closes the popup. +---`cascade` option only works with nested popups having `close_callback` set. +---@param opts nil|{cascade: boolean?} +function M.close_popup(opts) + local buf = vim.api.nvim_get_current_buf() + Popup.close(buf, opts) +end + +---Delete selected sessions from given buffer of a popup +---@param sessions string[] +local function delete_selected_sessions(sessions) + for _, session in ipairs(sessions) do + local result = vim.fn.delete(session) + if result > 0 then + vim.notify("Failed to delete file: " .. session .. " , exited with status: " .. result, vim.log.levels.ERROR) + end + end +end + +---Close the confirmation popup. The `confirm_close` popup behaves the same as `close_popup` but additionally +---deletes sessions that are removed from the buffer. +function M.confirm_close() + local buf = vim.api.nvim_get_current_buf() + local remove_sessions = (state[buf] or {}).remove_sessions or {} + + delete_selected_sessions(remove_sessions) + Popup.close(buf, { cascade = true }) +end + +---Toggles cursor selection between Y and N actions +function M.toggle_action_selection() + local buf = vim.api.nvim_get_current_buf() + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local no_pos = lines[#lines]:find("%(N%)") + local yes_pos = lines[#lines]:find("%(Y%)") + local win = vim.api.nvim_get_current_win() + + local current_char = get_current_char(win) + if current_char == "Y" then + vim.api.nvim_win_set_cursor(win, { #lines, no_pos }) + elseif current_char == "N" then + vim.api.nvim_win_set_cursor(win, { #lines, yes_pos }) + else + vim.api.nvim_win_set_cursor(win, { #lines, no_pos }) + end +end + +---Executes the action currently focused by the cursor. No-op if neither Y nor N is focused. +function M.execute_selected_action() + local win = vim.api.nvim_get_current_win() + local current_char = get_current_char(win) + + if current_char == "Y" then + M.confirm_close() + elseif current_char == "N" then + M.close_popup({ cascade = false }) + end +end + +return M diff --git a/lua/persistence/config.lua b/lua/persistence/config.lua index 1589857..83a44d4 100644 --- a/lua/persistence/config.lua +++ b/lua/persistence/config.lua @@ -1,5 +1,78 @@ +local actions = require("persistence.actions") + local M = {} +local map_opts = { noremap = false, silent = true } + +---@class Persistence.WinOpts +---@field height number +---@field width number +---@field title string|nil +---@field border string|nil + +---@class Persistence.ConfirmOpts +---@field width number +---@field title string|nil +---@field border string|nil + +---@class Persistence.KeyMap +---@field mode string +---@field action string|fun() +---@field opts vim.keymap.set.Opts + +---@class Persistence.ManageConfig +local manage = { + ---@type Persistence.WinOpts + list = { + width = 100, + height = 30, + title = " Manage sessions ", + }, + ---@type Persistence.ConfirmOpts + confirm = { + width = 80, + title = " Confirm ", + }, + ---@type {list: {[string]: Persistence.KeyMap}, confirm: {[string]: Persistence.KeyMap}} + keymaps = { + list = { + q = { + mode = "n", + opts = map_opts, + action = actions.close_popup, + }, + }, + confirm = { + y = { + mode = "n", + opts = map_opts, + action = actions.confirm_close, + }, + n = { + mode = "n", + opts = map_opts, + action = function() + actions.close_popup({ cascade = false }) + end, + }, + [""] = { + mode = "n", + opts = map_opts, + action = actions.toggle_action_selection, + }, + [""] = { + mode = "n", + opts = map_opts, + action = actions.execute_selected_action, + }, + }, + }, + highlight = { + confirm_warning = "ErrorMsg", + neutral = "@label", + }, +} + ---@class Persistence.Config local defaults = { dir = vim.fn.stdpath("state") .. "/sessions/", -- directory where session files are saved @@ -7,6 +80,7 @@ local defaults = { -- Set to 0 to always save need = 1, branch = true, -- use git branch to save session + manage = manage, } ---@type Persistence.Config diff --git a/lua/persistence/init.lua b/lua/persistence/init.lua index 760f6b6..4802577 100644 --- a/lua/persistence/init.lua +++ b/lua/persistence/init.lua @@ -1,4 +1,6 @@ local Config = require("persistence.config") +local manage = require("persistence.manage") +local ops = require("persistence.ops") local uv = vim.uv or vim.loop @@ -137,10 +139,12 @@ end --- get current branch name ---@return string? function M.branch() - if uv.fs_stat(".git") then - local ret = vim.fn.systemlist("git branch --show-current")[1] - return vim.v.shell_error == 0 and ret or nil - end + ops.git_branch() +end + +--- open manage sessions popup +function M.open_manage() + manage.open(M.list()) end return M diff --git a/lua/persistence/manage.lua b/lua/persistence/manage.lua new file mode 100644 index 0000000..a3ccea5 --- /dev/null +++ b/lua/persistence/manage.lua @@ -0,0 +1,171 @@ +local Config = require("persistence.config") +local Popup = require("persistence.popup") +local ops = require("persistence.ops") +local state = require("persistence.actions").state + +local M = {} + +---Restore original window focus and cursor position +---@param current_win integer window id +---@param cursor_pos integer[] cursor position tuple +local function restore_window_cursor(current_win, cursor_pos) + vim.api.nvim_win_set_cursor(current_win, cursor_pos) + vim.fn.win_gotoid(current_win) +end + +---@param sessions string[] +---@return string[] +local function to_session_files(sessions) + local files = {} + for _, session in ipairs(sessions) do + local name = ops.to_session_file_name(session) + table.insert(files, name) + end + + return files +end + +---@param parent_opts Persitence.Popup.CloseCallbackOpts +---@param entries string[] +---@param current_win integer +---@param cursor_pos integer[] +local function open_confirm_popup(parent_opts, entries, current_win, cursor_pos) + local lines = vim.api.nvim_buf_get_lines(parent_opts.buf, 0, -1, false) + + local content = { "Are you sure you want to delete following sessions?" } + + ---@type string[] + local removed = vim + .iter(entries) + :filter(function(v) + return vim.iter(lines):any(function(l) + return l == v + end) == false + end) + :totable() + content = vim.list_extend(content, removed) + local confirm_actions = "(Y)es (N)o" + local padding = "" + for _ = 1, (Config.options.manage.confirm.width / 2 - (#confirm_actions / 2)) do + padding = padding .. " " + end + vim.list_extend(content, { "", padding .. confirm_actions }) + + if #removed > 0 then + local buf = Popup.open({ + scratch = true, + ns = parent_opts.ns, + keymaps = Config.options.manage.keymaps.confirm, + ---@diagnostic disable-next-line: assign-type-mismatch + win = vim.tbl_deep_extend("force", Config.options.manage.confirm, { height = #content }), + content = content, + prepare = function(o) + for i, line in ipairs(content) do + if i > 1 and i < (#content - 1) then + vim.api.nvim_buf_set_extmark( + o.buf, + o.ns, + i - 1, + 0, + { hl_group = Config.options.manage.highlight.confirm_warning, end_col = #line } + ) + end + if i == #content then + local yes = line:find("%(Y%)") + vim.api.nvim_buf_set_extmark( + o.buf, + o.ns, + i - 1, + yes - 1, + { hl_group = Config.options.manage.highlight.confirm_warning, end_col = yes + 4 } + ) + local no = line:find("%(N%)") + vim.api.nvim_buf_set_extmark( + o.buf, + o.ns, + i - 1, + no - 1, + { hl_group = Config.options.manage.highlight.neutral, end_col = no + 3 } + ) + end + end + end, + after_win_create = function(o) + local no_pos = content[#content]:find("%(N%)") + vim.api.nvim_win_set_cursor(o.win, { #content, no_pos }) + end, + close_callback = function(opts) + opts.close() + if opts.cascade then + parent_opts.close() + restore_window_cursor(current_win, cursor_pos) + end + end, + }) + + state[buf] = { remove_sessions = to_session_files(removed) } + else + -- nothing to remove, just close the thing + parent_opts.close() + restore_window_cursor(current_win, cursor_pos) + end +end + +---@param ns integer +---@param entries string[] +---@param current_win integer +---@param cursor_pos integer[] +local function open_popup(ns, entries, current_win, cursor_pos) + Popup.open({ + scratch = false, + ns = ns, + content = entries, + keymaps = Config.options.manage.keymaps.list, + win = Config.options.manage.list, + prepare = function(o) + for i, line in ipairs(entries) do + local branch_icon_index = line:find("()") + if branch_icon_index ~= nil then + local len = line:len() + if branch_icon_index ~= nil then + vim.api.nvim_buf_set_extmark( + o.buf, + o.ns, + i - 1, + branch_icon_index - 1, + { hl_group = Config.options.manage.highlight.neutral, end_col = len } + ) + end + end + end + end, + close_callback = function(opts) + open_confirm_popup(opts, entries, current_win, cursor_pos) + end, + }) +end + +---Open persistence session manager popup +---@param sessions string[] +function M.open(sessions) + local current_win = vim.api.nvim_get_current_win() + local cursor_pos = vim.api.nvim_win_get_cursor(current_win) + + local ns = vim.api.nvim_create_namespace("persistence.manage") + local sessions_dir = Config.options.dir + + ---@type string[] + local entries = {} + for _, session in ipairs(sessions) do + if vim.uv.fs_stat(session) then + local file = session:sub(#sessions_dir + 1, -5) + local dir = ops.to_display_line(file) + table.insert(entries, dir) + else + table.insert(entries, session) + end + end + open_popup(ns, entries, current_win, cursor_pos) +end + +return M diff --git a/lua/persistence/ops.lua b/lua/persistence/ops.lua new file mode 100644 index 0000000..3be96ef --- /dev/null +++ b/lua/persistence/ops.lua @@ -0,0 +1,52 @@ +local Config = require("persistence.config") + +local M = {} + +local uv = vim.uv or vim.loop + +--- get current branch name +---@return string? +function M.git_branch() + if uv.fs_stat(".git") then + local ret = vim.fn.systemlist("git branch --show-current")[1] + return vim.v.shell_error == 0 and ret or nil + end +end + +---Convert session line to display line +---@param file string persistence session file to convert to display line +---@return string +function M.to_display_line(file) + local show_branch = Config.options.branch + + local dir, branch = unpack(vim.split(file, "%%", { plain = true })) + dir = dir:gsub("%%", "/") + if jit.os:find("Windows") then + dir = dir:gsub("^(%w)/", "%1:/") + end + dir = dir:gsub(vim.env.HOME, "~") + if show_branch and branch ~= nil then + local icon = "" + local branch_ext = " " .. icon .. " " .. branch + dir = dir .. branch_ext + end + return dir +end + +---Convert display line to session folder +---@param line string display line to convert to fully qualified persistence session file name +---@return string +function M.to_session_file_name(line) + local name = line:gsub("~", vim.env.HOME):gsub("[\\/:]+", "%%") + + if Config.options.branch then + local branch = M.git_branch() + if branch and branch ~= "main" and branch ~= "master" then + name = name .. "%%" .. branch:gsub("[\\/:]+", "%%") + end + end + + return Config.options.dir .. name .. ".vim" +end + +return M diff --git a/lua/persistence/popup.lua b/lua/persistence/popup.lua new file mode 100644 index 0000000..218313f --- /dev/null +++ b/lua/persistence/popup.lua @@ -0,0 +1,114 @@ +local M = {} + +---@type {[integer]: Persitence.Popup} +local state = {} + +local Popup = {} + +---@class Persitence.Popup.CloseCallbackOpts +---@field buf integer +---@field ns number +---@field cascade boolean +---@field close fun() + +---@class Persitence.Popup.Opts +---@field win Persistence.WinOpts +---@field scratch boolean +---@field ns integer namespace +---@field content string[] +---@field keymaps {[string]: Persistence.KeyMap} +---@field close_callback nil|fun(x:Persitence.Popup.CloseCallbackOpts) +---@field prepare nil|fun(o:{buf: integer, ns: integer}) +---@field after_win_create nil|fun(opts:{buf: integer, ns: number, win: integer}) + +---@class Persitence.Popup +---@field win integer +---@field buf integer +---@field opts Opts + +---Open a simple popup window +---@param opts Persitence.Popup.Opts +---@return Persitence.Popup +function Popup.open(opts) + local buf = vim.api.nvim_create_buf(false, opts.scratch) + + local col = (vim.o.columns / 2) - (opts.win.width / 2) + local row = ((vim.o.lines - vim.o.cmdheight) / 2) - (opts.win.height / 2) + + local config = vim.tbl_deep_extend("force", { + style = "minimal", + relative = "editor", + row = row, + col = col, + title_pos = "center", + border = "rounded", + }, opts.win) + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, opts.content) + + if opts.prepare ~= nil then + opts.prepare({ buf = buf, ns = opts.ns }) + end + + for key, map in pairs(opts.keymaps) do + map.opts.buffer = buf + vim.keymap.set(map.mode, key, map.action, map.opts) + end + + local win = vim.api.nvim_open_win(buf, true, config) + if opts.after_win_create ~= nil then + opts.after_win_create({ buf = buf, ns = opts.ns, win = win }) + end + + return { + win = win, + buf = buf, + opts = opts, + } +end + +---@param popup Persitence.Popup +---@param opts {cascade: boolean} +function Popup.close(popup, opts) + if popup.opts.close_callback ~= nil then + popup.opts.close_callback({ + buf = popup.buf, + ns = popup.opts.ns, + cascade = opts.cascade, + close = function() + -- close and remove the buffer + vim.api.nvim_buf_delete(popup.buf, { force = true }) + if vim.api.nvim_win_is_valid(popup.win) then + vim.api.nvim_win_close(popup.win, true) + end + end, + }) + else + -- close and remove the buffer + vim.api.nvim_buf_delete(popup.buf, { force = true }) + if vim.api.nvim_win_is_valid(popup.win) then + vim.api.nvim_win_close(popup.win, true) + end + end +end + +---Open a popup according to the given options. +---@param opts Persitence.Popup.Opts +---@return integer buf buffer number of the popup +function M.open(opts) + local p = Popup.open(opts) + state[p.buf] = p + + return p.buf +end + +--- Closes the popup for given buffer number according to the configured +--- `close_callback`. +---@param buf integer buffer number of the poopup +---@param opts nil|{cascade: boolean?} +function M.close(buf, opts) + local popup = state[buf] + Popup.close(popup, vim.tbl_deep_extend("force", { cascade = true }, opts or {})) +end + +return M