Skip to content

Commit 138cec0

Browse files
authored
Merge pull request #14 from mhiro2/fix/prevent-keymap-leakage
fix(ui): Prevent keymap leakage and harden persist/config reliability
2 parents 85ca1c9 + 14ce086 commit 138cec0

13 files changed

Lines changed: 369 additions & 58 deletions

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,15 @@ Using lazy.nvim:
6262
})
6363

6464
-- LSP: peek at definitions and references
65-
vim.keymap.set("n", "pd", function() peekstack.peek.definition() end)
66-
vim.keymap.set("n", "pr", function() peekstack.peek.references() end)
65+
vim.keymap.set("n", "<leader>pd", function() peekstack.peek.definition() end)
66+
vim.keymap.set("n", "<leader>pr", function() peekstack.peek.references() end)
6767

6868
-- Diagnostics & files: peek at diagnostics or files under cursor
69-
vim.keymap.set("n", "pl", function() peekstack.peek.diagnostics_cursor() end)
70-
vim.keymap.set("n", "pf", function() peekstack.peek.file_under_cursor() end)
69+
vim.keymap.set("n", "<leader>pl", function() peekstack.peek.diagnostics_cursor() end)
70+
vim.keymap.set("n", "<leader>pf", function() peekstack.peek.file_under_cursor() end)
7171

7272
-- Marks: browse buffer marks (requires marks provider enabled)
73-
vim.keymap.set("n", "pm", function() peekstack.peek.marks_buffer() end)
73+
vim.keymap.set("n", "<leader>pm", function() peekstack.peek.marks_buffer() end)
7474
end,
7575
}
7676
```

lua/peekstack/config.lua

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,17 +199,35 @@ local config = vim.deepcopy(M.defaults)
199199

200200
---@param path string
201201
---@param value any
202-
local function validate_event_list(path, value)
202+
---@param default string[]
203+
---@return string[]
204+
local function sanitize_event_list(path, value, default)
203205
if type(value) ~= "table" then
204-
notify.warn(string.format("%s must be a list of strings", path))
205-
return
206+
notify.warn(string.format("%s must be a list of strings. Falling back to defaults", path))
207+
return vim.deepcopy(default)
206208
end
207-
for idx, event in ipairs(value) do
208-
if type(event) ~= "string" then
209-
notify.warn(string.format("%s[%d] must be a string, got %s", path, idx, type(event)))
210-
return
209+
210+
---@type string[]
211+
local events = {}
212+
local invalid_count = 0
213+
for _, event in ipairs(value) do
214+
if type(event) == "string" and event ~= "" then
215+
events[#events + 1] = event
216+
else
217+
invalid_count = invalid_count + 1
211218
end
212219
end
220+
221+
if invalid_count > 0 then
222+
notify.warn(string.format("%s contains %d invalid entries. Ignoring invalid values", path, invalid_count))
223+
end
224+
225+
if #events == 0 then
226+
notify.warn(string.format("%s must contain at least one valid event. Falling back to defaults", path))
227+
return vim.deepcopy(default)
228+
end
229+
230+
return events
213231
end
214232

215233
---@alias PeekstackConfigFieldValidator fun(path: string, value: any, default: any): any
@@ -268,9 +286,8 @@ end
268286

269287
---@return PeekstackConfigFieldValidator
270288
local function field_event_list()
271-
return function(path, value, _default)
272-
validate_event_list(path, value)
273-
return value
289+
return function(path, value, default)
290+
return sanitize_event_list(path, value, default)
274291
end
275292
end
276293

lua/peekstack/persist/auto.lua

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ local function resolve_root_winid()
5454
end
5555

5656
---@param root_winid? integer
57+
---@param opts? { sync?: boolean }
5758
---@return boolean
58-
local function save_session(root_winid)
59+
local function save_session(root_winid, opts)
5960
if not is_enabled() then
6061
return false
6162
end
@@ -66,6 +67,7 @@ local function save_session(root_winid)
6667
scope = "repo",
6768
root_winid = normalize_root_winid(root_winid),
6869
silent = true,
70+
sync = opts and opts.sync or false,
6971
})
7072
return true
7173
end
@@ -162,7 +164,7 @@ function M.save_on_leave(opts)
162164
save_timer:stop()
163165
end
164166

165-
return save_session(root_winid)
167+
return save_session(root_winid, { sync = true })
166168
end
167169

168170
function M.setup()

lua/peekstack/persist/init.lua

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,10 @@ end
6666

6767
---Save the current stack to persistent storage with optional name
6868
---@param name? string
69-
---@param opts? { scope?: string, root_winid?: integer, silent?: boolean, on_done?: fun(success: boolean) }
69+
---@param opts? { scope?: string, root_winid?: integer, silent?: boolean, sync?: boolean, on_done?: fun(success: boolean) }
7070
function M.save_current(name, opts)
7171
local silent = opts and opts.silent or false
72+
local sync = opts and opts.sync or false
7273
local on_done = opts and opts.on_done or nil
7374
local function finish(success)
7475
if on_done then
@@ -77,6 +78,7 @@ function M.save_current(name, opts)
7778
end
7879

7980
if not ensure_enabled(silent) then
81+
finish(false)
8082
return
8183
end
8284

@@ -100,42 +102,62 @@ function M.save_current(name, opts)
100102
data_items = vim.list_slice(data_items, #data_items - max_items + 1, #data_items)
101103
end
102104

103-
store.read(scope, {
104-
on_done = function(read_data)
105-
local data = migrate.ensure(read_data)
106-
local now = os.time()
105+
---@param data PeekstackStoreData
106+
---@return PeekstackStoreData
107+
local function upsert_session(data)
108+
local now = os.time()
109+
if data.sessions[resolved_name] then
110+
data.sessions[resolved_name].items = data_items
111+
data.sessions[resolved_name].meta.updated_at = now
112+
else
113+
data.sessions[resolved_name] = {
114+
items = data_items,
115+
meta = {
116+
created_at = now,
117+
updated_at = now,
118+
},
119+
}
120+
end
121+
return data
122+
end
107123

108-
if data.sessions[resolved_name] then
109-
data.sessions[resolved_name].items = data_items
110-
data.sessions[resolved_name].meta.updated_at = now
124+
local function notify_save_result(success)
125+
if not silent then
126+
if success then
127+
vim.notify("Session saved: " .. resolved_name, vim.log.levels.INFO)
111128
else
112-
data.sessions[resolved_name] = {
113-
items = data_items,
114-
meta = {
115-
created_at = now,
116-
updated_at = now,
117-
},
118-
}
129+
vim.notify("Failed to save session: " .. resolved_name, vim.log.levels.WARN)
119130
end
131+
end
132+
if success then
133+
user_events.emit("PeekstackSave", {
134+
session = resolved_name,
135+
item_count = #data_items,
136+
})
137+
end
138+
end
139+
140+
if sync then
141+
local data = upsert_session(migrate.ensure(store.read_sync(scope)))
142+
local success = store.write_sync(scope, data)
143+
if success then
144+
update_cache(data)
145+
end
146+
notify_save_result(success)
147+
finish(success)
148+
return
149+
end
150+
151+
store.read(scope, {
152+
on_done = function(read_data)
153+
local data = upsert_session(migrate.ensure(read_data))
120154

121155
store.write(scope, data, {
122156
on_done = function(success)
123157
if success then
124158
update_cache(data)
125159
end
126-
if not silent then
127-
if success then
128-
vim.notify("Session saved: " .. resolved_name, vim.log.levels.INFO)
129-
else
130-
vim.notify("Failed to save session: " .. resolved_name, vim.log.levels.WARN)
131-
end
132-
end
133-
if success then
134-
user_events.emit("PeekstackSave", {
135-
session = resolved_name,
136-
item_count = #data_items,
137-
})
138-
end
160+
notify_save_result(success)
139161
finish(success)
140162
end,
141163
})
@@ -156,6 +178,7 @@ function M.restore(name, opts)
156178
end
157179

158180
if not ensure_enabled(silent) then
181+
finish(false)
159182
return
160183
end
161184

lua/peekstack/persist/store.lua

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,34 @@ local function empty_data()
77
return { version = 2, sessions = {} }
88
end
99

10+
---@param data PeekstackStoreData
11+
---@return string?
12+
local function encode_data(data)
13+
local ok, encoded = pcall(vim.json.encode, data)
14+
if not ok then
15+
vim.notify("Failed to encode session data", vim.log.levels.WARN)
16+
return nil
17+
end
18+
return encoded
19+
end
20+
21+
---@param path string
22+
---@return boolean
23+
local function ensure_parent_dir(path)
24+
local dir = vim.fs.dirname(path)
25+
if vim.uv.fs_stat(dir) then
26+
return true
27+
end
28+
29+
local mkdir_ok = pcall(vim.fn.mkdir, dir, "p")
30+
if not mkdir_ok then
31+
vim.notify("Failed to create directory: " .. dir, vim.log.levels.WARN)
32+
return false
33+
end
34+
35+
return true
36+
end
37+
1038
---@param scope string
1139
---@param opts { on_done: fun(data: PeekstackStoreData) }
1240
function M.read(scope, opts)
@@ -94,22 +122,17 @@ function M.write(scope, data, opts)
94122
end
95123

96124
local path = fs.scope_path(scope)
97-
local ok, encoded = pcall(vim.json.encode, data)
98-
if not ok then
99-
vim.notify("Failed to encode session data", vim.log.levels.WARN)
125+
local encoded = encode_data(data)
126+
if not encoded then
100127
finish(false)
101128
return
102129
end
103-
local dir = vim.fs.dirname(path)
104-
local dir_stat = vim.uv.fs_stat(dir)
105-
if not dir_stat then
106-
local mkdir_ok = pcall(vim.fn.mkdir, dir, "p")
107-
if not mkdir_ok then
108-
vim.notify("Failed to create directory: " .. dir, vim.log.levels.WARN)
109-
finish(false)
110-
return
111-
end
130+
131+
if not ensure_parent_dir(path) then
132+
finish(false)
133+
return
112134
end
135+
113136
local tmp_path = path .. ".tmp"
114137
vim.uv.fs_open(tmp_path, "w", 438, function(open_err, fd)
115138
if open_err or not fd then
@@ -145,4 +168,43 @@ function M.write(scope, data, opts)
145168
end)
146169
end
147170

171+
---@param scope string
172+
---@param data PeekstackStoreData
173+
---@return boolean
174+
function M.write_sync(scope, data)
175+
local path = fs.scope_path(scope)
176+
local encoded = encode_data(data)
177+
if not encoded then
178+
return false
179+
end
180+
181+
if not ensure_parent_dir(path) then
182+
return false
183+
end
184+
185+
local tmp_path = path .. ".tmp"
186+
local fd = vim.uv.fs_open(tmp_path, "w", 438)
187+
if not fd then
188+
vim.notify("Failed to write session data: " .. path, vim.log.levels.WARN)
189+
return false
190+
end
191+
192+
local write_ok = vim.uv.fs_write(fd, encoded, 0)
193+
pcall(vim.uv.fs_close, fd)
194+
if not write_ok then
195+
vim.notify("Failed to write session data: " .. path, vim.log.levels.WARN)
196+
pcall(vim.uv.fs_unlink, tmp_path)
197+
return false
198+
end
199+
200+
local rename_ok = vim.uv.fs_rename(tmp_path, path)
201+
if not rename_ok then
202+
vim.notify("Failed to write session data: " .. path, vim.log.levels.WARN)
203+
pcall(vim.uv.fs_unlink, tmp_path)
204+
return false
205+
end
206+
207+
return true
208+
end
209+
148210
return M

lua/peekstack/ui/keymaps.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ end
2626

2727
---@param popup table
2828
function M.apply_popup(popup)
29+
if popup.buffer_mode == "source" then
30+
-- Source mode uses the real editing buffer, so buffer-local mappings
31+
-- would leak into normal editing after popup close.
32+
return
33+
end
34+
2935
local keys = config.get().ui.keys
3036
local popup_id = popup.id
3137

tests/config_spec.lua

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,45 @@ describe("config", function()
204204
assert.equals(config.defaults.ui.inline_preview.enabled, cfg.ui.inline_preview.enabled)
205205
assert.equals(config.defaults.ui.inline_preview.max_lines, cfg.ui.inline_preview.max_lines)
206206
assert.equals(config.defaults.ui.inline_preview.hl_group, cfg.ui.inline_preview.hl_group)
207+
assert.same(config.defaults.ui.inline_preview.close_events, cfg.ui.inline_preview.close_events)
207208
end)
208209

209210
it("warns on invalid quick peek config", function()
210-
config.setup({
211+
local cfg = config.setup({
211212
ui = {
212213
quick_peek = {
213214
close_events = { 1, 2 },
214215
},
215216
},
216217
})
217218
assert.is_true(has_message("ui.quick_peek.close_events"))
219+
assert.same(config.defaults.ui.quick_peek.close_events, cfg.ui.quick_peek.close_events)
220+
end)
221+
222+
it("sanitizes mixed quick peek close_events values", function()
223+
local cfg = config.setup({
224+
ui = {
225+
quick_peek = {
226+
close_events = { "CursorMoved", 1, "", "InsertEnter" },
227+
},
228+
},
229+
})
230+
231+
assert.is_true(has_message("ui.quick_peek.close_events"))
232+
assert.same({ "CursorMoved", "InsertEnter" }, cfg.ui.quick_peek.close_events)
233+
end)
234+
235+
it("falls back when quick peek close_events is empty", function()
236+
local cfg = config.setup({
237+
ui = {
238+
quick_peek = {
239+
close_events = {},
240+
},
241+
},
242+
})
243+
244+
assert.is_true(has_message("ui.quick_peek.close_events"))
245+
assert.same(config.defaults.ui.quick_peek.close_events, cfg.ui.quick_peek.close_events)
218246
end)
219247

220248
it("warns on invalid path config", function()

0 commit comments

Comments
 (0)