From fdd6e1fc497b066d7ccdbd62dcee3e8bda0d6313 Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Sat, 14 Jun 2025 03:56:50 +0300 Subject: [PATCH] feat(manage-ui): add session management ui This PR implements a session management popup UI for existing sessions. The popup is configurable and comes with "sensible" defaults (at least to me). The popup enables users to remove unnecessary or stale sessions in nvim.oil fashion, just as simple as editing a buffer. After removing stale sessions and quitting the popup, user will be prompted to confirm changes. --- lua/persistence/actions.lua | 78 ++++++++++++++++ lua/persistence/config.lua | 74 ++++++++++++++++ lua/persistence/init.lua | 12 ++- lua/persistence/manage.lua | 171 ++++++++++++++++++++++++++++++++++++ lua/persistence/ops.lua | 52 +++++++++++ lua/persistence/popup.lua | 114 ++++++++++++++++++++++++ 6 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 lua/persistence/actions.lua create mode 100644 lua/persistence/manage.lua create mode 100644 lua/persistence/ops.lua create mode 100644 lua/persistence/popup.lua 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