diff --git a/lua/gitlad/ui/hl.lua b/lua/gitlad/ui/hl.lua index ec867aa..082ebf5 100644 --- a/lua/gitlad/ui/hl.lua +++ b/lua/gitlad/ui/hl.lua @@ -146,6 +146,7 @@ local highlight_groups = { GitladPopupHeading = { link = "Title" }, GitladPopupSwitchKey = { link = "Special" }, GitladPopupSwitchEnabled = { link = "DiagnosticOk" }, + GitladPopupSwitchPersistent = { link = "DiagnosticHint" }, GitladPopupOptionKey = { link = "Keyword" }, GitladPopupActionKey = { link = "Keyword" }, GitladPopupDescription = { link = "Normal" }, diff --git a/lua/gitlad/ui/hl_status.lua b/lua/gitlad/ui/hl_status.lua index 649249c..d501971 100644 --- a/lua/gitlad/ui/hl_status.lua +++ b/lua/gitlad/ui/hl_status.lua @@ -793,7 +793,7 @@ function M.apply_popup_highlights( -- Build lookup for switch keys local switch_keys = {} for _, sw in ipairs(switches) do - switch_keys[sw.key] = sw.enabled + switch_keys[sw.key] = { enabled = sw.enabled, persistent = sw.persist_key ~= nil } end -- Build lookup for option keys @@ -834,7 +834,7 @@ function M.apply_popup_highlights( -- Switch line: " -a description (--flag)" local switch_key = line:match("^%s%s%-(%S)") if switch_key and switch_keys[switch_key] ~= nil then - local is_enabled = switch_keys[switch_key] + local sw_info = switch_keys[switch_key] -- Highlight the -key part local key_start = line:find("%-") if key_start then @@ -848,11 +848,19 @@ function M.apply_popup_highlights( ) end -- Highlight the flag text inside parens (not the parens themselves) + -- Persistent+enabled switches use a distinct color from transient+enabled local cli_start = line:find("%(%-") if cli_start then local cli_end = line:find("%)", cli_start) if cli_end then - local hl_group = is_enabled and "GitladPopupSwitchEnabled" or "Comment" + local hl_group + if sw_info.enabled and sw_info.persistent then + hl_group = "GitladPopupSwitchPersistent" + elseif sw_info.enabled then + hl_group = "GitladPopupSwitchEnabled" + else + hl_group = "Comment" + end hl_module.set(bufnr, ns_popup, line_idx, cli_start, cli_end - 1, hl_group) end end diff --git a/lua/gitlad/ui/popup/init.lua b/lua/gitlad/ui/popup/init.lua index eaa483f..c483527 100644 --- a/lua/gitlad/ui/popup/init.lua +++ b/lua/gitlad/ui/popup/init.lua @@ -17,6 +17,7 @@ local git = require("gitlad.git") ---@field enabled boolean Current state ---@field cli_prefix string Prefix for CLI flag (default "--") ---@field exclusive_with? string[] CLI names of switches that are mutually exclusive +---@field persist_key? string If set, enabled state is saved/loaded across popup invocations ---@class PopupOption ---@field key string Single character key binding @@ -126,7 +127,7 @@ end ---@param key string Single character key ---@param cli string CLI flag name (without --) ---@param description string User-facing description ----@param opts? { enabled?: boolean, cli_prefix?: string, exclusive_with?: string[] } +---@param opts? { enabled?: boolean, cli_prefix?: string, exclusive_with?: string[], persist_key?: string } ---@return PopupBuilder function PopupBuilder:switch(key, cli, description, opts) opts = opts or {} @@ -137,6 +138,7 @@ function PopupBuilder:switch(key, cli, description, opts) enabled = opts.enabled or false, cli_prefix = opts.cli_prefix or "--", exclusive_with = opts.exclusive_with, + persist_key = opts.persist_key, }) return self end @@ -292,6 +294,17 @@ function PopupBuilder:build() local data = setmetatable({}, PopupData) data.name = self._name data.switches = vim.deepcopy(self._switches) + + -- Apply persisted enabled states for sticky switches + local persist = require("gitlad.utils.persist") + for _, sw in ipairs(data.switches) do + if sw.persist_key then + local saved = persist.get(sw.persist_key) + if saved ~= nil then + sw.enabled = saved + end + end + end data.options = vim.deepcopy(self._options) data.actions = vim.deepcopy(self._actions) data.branch_scope = self._branch_scope @@ -372,6 +385,7 @@ end --- Toggle a switch by key ---@param key string Switch key function PopupData:toggle_switch(key) + local persist = require("gitlad.utils.persist") for _, sw in ipairs(self.switches) do if sw.key == key then sw.enabled = not sw.enabled @@ -381,10 +395,16 @@ function PopupData:toggle_switch(key) for _, excl_cli in ipairs(sw.exclusive_with) do if other.cli == excl_cli then other.enabled = false + if other.persist_key then + persist.set(other.persist_key, false) + end end end end end + if sw.persist_key then + persist.set(sw.persist_key, sw.enabled) + end return end end diff --git a/lua/gitlad/utils/init.lua b/lua/gitlad/utils/init.lua index 9de39f2..2bf0372 100644 --- a/lua/gitlad/utils/init.lua +++ b/lua/gitlad/utils/init.lua @@ -8,6 +8,7 @@ local M = {} M.errors = require("gitlad.utils.errors") M.keymap = require("gitlad.utils.keymap") M.path = require("gitlad.utils.path") +M.persist = require("gitlad.utils.persist") M.prompt = require("gitlad.utils.prompt") M.remote = require("gitlad.utils.remote") diff --git a/lua/gitlad/utils/persist.lua b/lua/gitlad/utils/persist.lua new file mode 100644 index 0000000..34e8c23 --- /dev/null +++ b/lua/gitlad/utils/persist.lua @@ -0,0 +1,75 @@ +---@mod gitlad.utils.persist Lightweight key-value persistence +---@brief [[ +--- Simple persistent store for popup switch states and similar small values. +--- Data is stored as JSON in stdpath("data")/gitlad/popup_switches.json. +--- +--- Override M._override_path in tests to avoid touching the real data directory. +---@brief ]] + +local M = {} + +--- Override storage path (for tests) +---@type string|nil +M._override_path = nil + +---@return string +local function data_file() + return M._override_path or (vim.fn.stdpath("data") .. "/gitlad/popup_switches.json") +end + +---@type table|nil +local _cache = nil + +--- Reset the in-memory cache (call after changing _override_path in tests) +function M._reset_cache() + _cache = nil +end + +---@return table +local function load() + if _cache then + return _cache + end + local path = data_file() + local f = io.open(path, "r") + if not f then + _cache = {} + return _cache + end + local content = f:read("*a") + f:close() + local ok, data = pcall(vim.json.decode, content) + _cache = (ok and type(data) == "table") and data or {} + return _cache +end + +---@param data table +local function save(data) + local path = data_file() + local dir = vim.fn.fnamemodify(path, ":h") + vim.fn.mkdir(dir, "p") + local f = io.open(path, "w") + if not f then + return + end + f:write(vim.json.encode(data)) + f:close() +end + +--- Get a persisted value by key +---@param key string +---@return any +function M.get(key) + return load()[key] +end + +--- Set a persisted value by key +---@param key string +---@param value any +function M.set(key, value) + local data = load() + data[key] = value + save(data) +end + +return M diff --git a/tests/e2e/test_popup_persist.lua b/tests/e2e/test_popup_persist.lua new file mode 100644 index 0000000..d7e1c46 --- /dev/null +++ b/tests/e2e/test_popup_persist.lua @@ -0,0 +1,234 @@ +-- E2E tests for popup persistent switches +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + local child = MiniTest.new_child_neovim() + child.start({ "-u", "tests/minimal_init.lua" }) + -- Point persist module to a temp file so tests don't pollute real data dir + child.lua([[ + local persist = require("gitlad.utils.persist") + persist._override_path = vim.fn.tempname() .. "_e2e_persist_test.json" + persist._reset_cache() + ]]) + _G.child = child + end, + post_case = function() + if _G.child then + -- Clean up temp file + _G.child.lua([[ + local persist = require("gitlad.utils.persist") + if persist._override_path then + os.remove(persist._override_path) + end + ]]) + _G.child.stop() + _G.child = nil + end + end, + }, +}) + +T["persistent switch survives popup close and reopen"] = function() + local child = _G.child + + -- Open popup with a persistent switch, show it + child.lua([[ + popup = require("gitlad.ui.popup") + test_popup = popup.builder() + :name("Test") + :switch("i", "copy-ignored", "Copy ignored files", { persist_key = "wt_copy_ignored" }) + :action("s", "Switch", function() end) + :build() + test_popup:show() + ]]) + + -- Verify switch starts disabled + local enabled = child.lua_get([[test_popup.switches[1].enabled]]) + eq(enabled, false) + + -- Toggle the persistent switch on + child.type_keys("-i") + enabled = child.lua_get([[test_popup.switches[1].enabled]]) + eq(enabled, true) + + -- Close the popup + child.type_keys("q") + + -- Reopen a new popup instance with the same persist_key + child.lua([[ + test_popup2 = popup.builder() + :name("Test") + :switch("i", "copy-ignored", "Copy ignored files", { persist_key = "wt_copy_ignored" }) + :action("s", "Switch", function() end) + :build() + test_popup2:show() + ]]) + + -- Persistent state should be loaded: switch is enabled + enabled = child.lua_get([[test_popup2.switches[1].enabled]]) + eq(enabled, true) + + -- Popup buffer should reflect enabled state in rendered lines + local lines = child.lua_get([[vim.api.nvim_buf_get_lines(test_popup2.buffer, 0, -1, false)]]) + local found_switch = false + for _, line in ipairs(lines) do + if line:match("%-i.*Copy ignored") then + found_switch = true + end + end + eq(found_switch, true) + + child.type_keys("q") +end + +T["non-persistent switch does not reload after reopen"] = function() + local child = _G.child + + child.lua([[ + popup = require("gitlad.ui.popup") + test_popup = popup.builder() + :name("Test") + :switch("v", "verbose", "Verbose") + :action("s", "Submit", function() end) + :build() + test_popup:show() + ]]) + + -- Toggle on + child.type_keys("-v") + local enabled = child.lua_get([[test_popup.switches[1].enabled]]) + eq(enabled, true) + + -- Close and reopen + child.type_keys("q") + + child.lua([[ + test_popup2 = popup.builder() + :name("Test") + :switch("v", "verbose", "Verbose") + :action("s", "Submit", function() end) + :build() + test_popup2:show() + ]]) + + -- Non-persistent: should be false again + enabled = child.lua_get([[test_popup2.switches[1].enabled]]) + eq(enabled, false) + + child.type_keys("q") +end + +T["toggling persistent switch off also persists the off state"] = function() + local child = _G.child + + child.lua([[ + popup = require("gitlad.ui.popup") + -- Pre-seed persist with true so popup opens enabled + local persist = require("gitlad.utils.persist") + persist.set("wt_flag", true) + test_popup = popup.builder() + :name("Test") + :switch("f", "flag", "My flag", { persist_key = "wt_flag" }) + :action("s", "Submit", function() end) + :build() + test_popup:show() + ]]) + + -- Loaded as enabled from persisted value + local enabled = child.lua_get([[test_popup.switches[1].enabled]]) + eq(enabled, true) + + -- Toggle off + child.type_keys("-f") + enabled = child.lua_get([[test_popup.switches[1].enabled]]) + eq(enabled, false) + + -- Close and reopen: should come back as false + child.type_keys("q") + + child.lua([[ + test_popup2 = popup.builder() + :name("Test") + :switch("f", "flag", "My flag", { persist_key = "wt_flag" }) + :action("s", "Submit", function() end) + :build() + test_popup2:show() + ]]) + + enabled = child.lua_get([[test_popup2.switches[1].enabled]]) + eq(enabled, false) + + child.type_keys("q") +end + +T["persistent switch arguments included in get_arguments when enabled"] = function() + local child = _G.child + + child.lua([[ + popup = require("gitlad.ui.popup") + local persist = require("gitlad.utils.persist") + persist.set("wt_copy_ignored2", true) + test_popup = popup.builder() + :name("Test") + :switch("i", "copy-ignored", "Copy ignored", { persist_key = "wt_copy_ignored2" }) + :build() + ]]) + + local args = child.lua_get([[test_popup:get_arguments()]]) + eq(#args, 1) + eq(args[1], "--copy-ignored") +end + +-- Cross-session: write in one child, read back in a fresh child process. +-- This is the real-world case: user toggles a switch, restarts Neovim, +-- reopens the popup — switch should still be on. +T["cross-session: persistent switch survives Neovim restart"] = function() + local shared_path = vim.fn.tempname() .. "_cross_session_persist.json" + + -- Session 1: toggle switch on + local child1 = MiniTest.new_child_neovim() + child1.start({ "-u", "tests/minimal_init.lua" }) + child1.lua(string.format( + [[ + local persist = require("gitlad.utils.persist") + persist._override_path = %q + persist._reset_cache() + local popup = require("gitlad.ui.popup") + local p = popup.builder() + :switch("i", "copy-ignored", "Copy ignored", { persist_key = "wt_copy_ignored" }) + :build() + p:toggle_switch("i") + ]], + shared_path + )) + child1.stop() + + -- Session 2: fresh Neovim, same persist file — switch should be loaded as enabled + local child2 = MiniTest.new_child_neovim() + child2.start({ "-u", "tests/minimal_init.lua" }) + child2.lua(string.format( + [[ + local persist = require("gitlad.utils.persist") + persist._override_path = %q + persist._reset_cache() + ]], + shared_path + )) + child2.lua([[ + local popup = require("gitlad.ui.popup") + result_popup = popup.builder() + :switch("i", "copy-ignored", "Copy ignored", { persist_key = "wt_copy_ignored" }) + :build() + ]]) + + local enabled = child2.lua_get([[result_popup.switches[1].enabled]]) + child2.stop() + os.remove(shared_path) + + eq(enabled, true) +end + +return T diff --git a/tests/unit/test_popup_persist.lua b/tests/unit/test_popup_persist.lua new file mode 100644 index 0000000..ca1e3c2 --- /dev/null +++ b/tests/unit/test_popup_persist.lua @@ -0,0 +1,217 @@ +local MiniTest = require("mini.test") +local expect, eq = MiniTest.expect, MiniTest.expect.equality + +local T = MiniTest.new_set() + +-- Isolate each test to a fresh temp file so they don't interfere with each other +-- or with the real user data directory. +local function tmp_path() + return vim.fn.tempname() .. "_popup_persist_test.json" +end + +local function make_persist(path) + local p = require("gitlad.utils.persist") + p._override_path = path + p._reset_cache() + return p +end + +-- ────────────────────────────────────────────────────────────── +-- persist module +-- ────────────────────────────────────────────────────────────── + +T["persist"] = MiniTest.new_set() + +T["persist"]["get returns nil for unknown key"] = function() + local p = make_persist(tmp_path()) + eq(p.get("no_such_key"), nil) +end + +T["persist"]["set and get roundtrip boolean true"] = function() + local path = tmp_path() + local p = make_persist(path) + p.set("my_switch", true) + p._reset_cache() + eq(p.get("my_switch"), true) +end + +T["persist"]["set and get roundtrip boolean false"] = function() + local path = tmp_path() + local p = make_persist(path) + p.set("my_switch", false) + p._reset_cache() + eq(p.get("my_switch"), false) +end + +T["persist"]["multiple keys are independent"] = function() + local path = tmp_path() + local p = make_persist(path) + p.set("switch_a", true) + p.set("switch_b", false) + p._reset_cache() + eq(p.get("switch_a"), true) + eq(p.get("switch_b"), false) +end + +T["persist"]["overwrite existing key"] = function() + local path = tmp_path() + local p = make_persist(path) + p.set("my_switch", true) + p.set("my_switch", false) + p._reset_cache() + eq(p.get("my_switch"), false) +end + +T["persist"]["survives cache reset (reads from disk)"] = function() + local path = tmp_path() + local p = make_persist(path) + p.set("key1", true) + -- Reset cache to force disk read on next access + p._reset_cache() + eq(p.get("key1"), true) +end + +-- ────────────────────────────────────────────────────────────── +-- PopupBuilder persist_key integration +-- ────────────────────────────────────────────────────────────── + +T["popup persist_key"] = MiniTest.new_set() + +T["popup persist_key"]["switch without persist_key defaults to enabled=false"] = function() + local popup = require("gitlad.ui.popup") + local data = popup.builder():switch("a", "all", "All files"):build() + eq(data.switches[1].enabled, false) + eq(data.switches[1].persist_key, nil) +end + +T["popup persist_key"]["switch with persist_key loads false (no saved state)"] = function() + local path = tmp_path() + local p = make_persist(path) + -- No saved state yet + eq(p.get("test_switch"), nil) + + local popup = require("gitlad.ui.popup") + local data = + popup.builder():switch("a", "all", "All files", { persist_key = "test_switch" }):build() + -- No persisted value → falls back to opts.enabled default (false) + eq(data.switches[1].enabled, false) + eq(data.switches[1].persist_key, "test_switch") +end + +T["popup persist_key"]["switch with persist_key loads saved true state"] = function() + local path = tmp_path() + local p = make_persist(path) + p.set("sticky_verbose", true) + + local popup = require("gitlad.ui.popup") + local data = + popup.builder():switch("v", "verbose", "Verbose", { persist_key = "sticky_verbose" }):build() + eq(data.switches[1].enabled, true) +end + +T["popup persist_key"]["switch with persist_key loads saved false state (overrides enabled=true default)"] = function() + local path = tmp_path() + local p = make_persist(path) + p.set("sticky_flag", false) + + local popup = require("gitlad.ui.popup") + -- opts.enabled = true, but persisted value = false → persisted wins + local data = popup + .builder() + :switch("f", "force", "Force", { enabled = true, persist_key = "sticky_flag" }) + :build() + eq(data.switches[1].enabled, false) +end + +T["popup persist_key"]["toggle_switch saves state when persist_key is set"] = function() + local path = tmp_path() + local p = make_persist(path) + p._reset_cache() + + local popup = require("gitlad.ui.popup") + local data = + popup.builder():switch("a", "all", "All files", { persist_key = "save_test" }):build() + + eq(data.switches[1].enabled, false) + data:toggle_switch("a") + eq(data.switches[1].enabled, true) + + -- Value should have been saved to disk + p._reset_cache() + eq(p.get("save_test"), true) + + -- Toggle off + data:toggle_switch("a") + eq(data.switches[1].enabled, false) + p._reset_cache() + eq(p.get("save_test"), false) +end + +T["popup persist_key"]["toggle_switch does NOT save when no persist_key"] = function() + local path = tmp_path() + local p = make_persist(path) + + local popup = require("gitlad.ui.popup") + -- Use a key that won't collide with any persisted value + local data = popup.builder():switch("n", "no-verify", "No verify"):build() + + data:toggle_switch("n") + eq(data.switches[1].enabled, true) + + -- Nothing should have been written for this switch + p._reset_cache() + eq(p.get("no-verify"), nil) +end + +T["popup persist_key"]["rebuilt popup picks up previously toggled state"] = function() + local path = tmp_path() + local p = make_persist(path) + p._reset_cache() + + local popup = require("gitlad.ui.popup") + + -- First popup instance: toggle on + local data1 = popup + .builder() + :switch("c", "copy-ignored", "Copy ignored files", { persist_key = "wt_copy_ignored" }) + :build() + data1:toggle_switch("c") + eq(data1.switches[1].enabled, true) + + -- Second popup instance (simulates reopening): should load saved state + local data2 = popup + .builder() + :switch("c", "copy-ignored", "Copy ignored files", { persist_key = "wt_copy_ignored" }) + :build() + eq(data2.switches[1].enabled, true) +end + +T["popup persist_key"]["exclusive switch disables and persists the other"] = function() + local path = tmp_path() + local p = make_persist(path) + p.set("switch_x", true) + p.set("switch_y", false) + + local popup = require("gitlad.ui.popup") + local data = popup + .builder() + :switch("x", "opt-x", "Option X", { persist_key = "switch_x", exclusive_with = { "opt-y" } }) + :switch("y", "opt-y", "Option Y", { persist_key = "switch_y" }) + :build() + + -- switch_x is loaded as enabled (saved true), switch_y as disabled (saved false) + eq(data.switches[1].enabled, true) + eq(data.switches[2].enabled, false) + + -- Enable switch_y (exclusive with opt-x) → should disable and persist switch_x=false + data.switches[2].exclusive_with = { "opt-x" } + data:toggle_switch("y") + eq(data.switches[2].enabled, true) + eq(data.switches[1].enabled, false) + + p._reset_cache() + eq(p.get("switch_x"), false) + eq(p.get("switch_y"), true) +end + +return T