Skip to content

Commit 5384525

Browse files
authored
Merge pull request #26 from mhiro2/refactor/root-resolution-and-validation-hardening
fix(persist): preserve popup hierarchy across restore and harden config validation
2 parents b361af5 + 625790b commit 5384525

11 files changed

Lines changed: 790 additions & 40 deletions

File tree

lua/peekstack/config/validate/rules/persist.lua

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ local M = {}
44

55
---@type PeekstackConfigFieldRule[]
66
local PERSIST_RULES = {
7+
{ key = "enabled", validate = shared.field_type("boolean") },
78
{ key = "max_items", validate = shared.field_number_range({ min = 1 }) },
89
}
910

@@ -34,9 +35,11 @@ function M.validate(cfg, defaults)
3435

3536
shared.apply_rules(persist, "persist", defaults.persist, PERSIST_RULES)
3637

37-
local session = shared.as_table(persist.session)
38-
if session then
39-
shared.apply_rules(session, "persist.session", defaults.persist.session, PERSIST_SESSION_RULES)
38+
if persist.session ~= nil then
39+
local session = shared.ensure_table_field(persist, "session", "persist.session", defaults.persist.session)
40+
if session then
41+
shared.apply_rules(session, "persist.session", defaults.persist.session, PERSIST_SESSION_RULES)
42+
end
4043
end
4144

4245
if persist.auto ~= nil then

lua/peekstack/config/validate/rules/providers.lua

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@ local shared = require("peekstack.config.validate.shared")
22

33
local M = {}
44

5+
---@type PeekstackConfigFieldRule[]
6+
local PROVIDER_ENABLE_RULES = {
7+
{ key = "enable", validate = shared.field_type("boolean") },
8+
}
9+
510
---@type PeekstackConfigFieldRule[]
611
local MARKS_RULES = {
12+
{ key = "enable", validate = shared.field_type("boolean") },
713
{ key = "include", validate = shared.field_type("string") },
814
{ key = "include_special", validate = shared.field_type("boolean") },
915
}
@@ -16,9 +22,20 @@ function M.validate(cfg, defaults)
1622
return
1723
end
1824

19-
local marks = shared.as_table(providers.marks)
20-
if marks then
21-
shared.apply_rules(marks, "providers.marks", defaults.providers.marks, MARKS_RULES)
25+
for _, name in ipairs({ "lsp", "diagnostics", "file" }) do
26+
if providers[name] ~= nil then
27+
local provider = shared.ensure_table_field(providers, name, "providers." .. name, defaults.providers[name])
28+
if provider then
29+
shared.apply_rules(provider, "providers." .. name, defaults.providers[name], PROVIDER_ENABLE_RULES)
30+
end
31+
end
32+
end
33+
34+
if providers.marks ~= nil then
35+
local marks = shared.ensure_table_field(providers, "marks", "providers.marks", defaults.providers.marks)
36+
if marks then
37+
shared.apply_rules(marks, "providers.marks", defaults.providers.marks, MARKS_RULES)
38+
end
2239
end
2340
end
2441

lua/peekstack/config/validate/rules/ui.lua

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,37 @@ local QUICK_PEEK_RULES = {
6060
{ key = "close_events", validate = shared.field_event_list() },
6161
}
6262

63+
---@type PeekstackConfigFieldRule[]
64+
local POPUP_AUTO_CLOSE_RULES = {
65+
{ key = "enabled", validate = shared.field_type("boolean") },
66+
{ key = "idle_ms", validate = shared.field_number_range({ min = 1 }) },
67+
{ key = "check_interval_ms", validate = shared.field_number_range({ min = 1 }) },
68+
{ key = "ignore_pinned", validate = shared.field_type("boolean") },
69+
}
70+
71+
---@type PeekstackConfigFieldRule[]
72+
local FEEDBACK_RULES = {
73+
{ key = "highlight_origin_on_close", validate = shared.field_type("boolean") },
74+
}
75+
76+
---@type PeekstackConfigFieldRule[]
77+
local PROMOTE_RULES = {
78+
{ key = "close_popup", validate = shared.field_type("boolean") },
79+
}
80+
81+
---@type PeekstackConfigFieldRule[]
82+
local TITLE_RULES = {
83+
{ key = "enabled", validate = shared.field_type("boolean") },
84+
{ key = "format", validate = shared.field_type("string") },
85+
}
86+
87+
---@type PeekstackConfigFieldRule[]
88+
local TITLE_CONTEXT_RULES = {
89+
{ key = "enabled", validate = shared.field_type("boolean") },
90+
{ key = "max_depth", validate = shared.field_number_range({ min = 1 }) },
91+
{ key = "separator", validate = shared.field_type("string") },
92+
}
93+
6394
---@type PeekstackConfigFieldRule[]
6495
local TITLE_ICON_RULES = {
6596
{ key = "enabled", validate = shared.field_type("boolean") },
@@ -119,28 +150,45 @@ end
119150
---@param ui table
120151
---@param defaults PeekstackConfigUI
121152
local function validate_popup(ui, defaults)
122-
local popup = shared.as_table(ui.popup)
153+
if ui.popup == nil then
154+
return
155+
end
156+
local popup = shared.ensure_table_field(ui, "popup", "ui.popup", defaults.popup)
123157
if not popup then
124158
return
125159
end
126160

127161
shared.apply_rules(popup, "ui.popup", defaults.popup, POPUP_RULES)
128162

129-
local source = shared.as_table(popup.source)
130-
if source then
131-
shared.apply_rules(source, "ui.popup.source", defaults.popup.source, POPUP_SOURCE_RULES)
163+
if popup.source ~= nil then
164+
local source = shared.ensure_table_field(popup, "source", "ui.popup.source", defaults.popup.source)
165+
if source then
166+
shared.apply_rules(source, "ui.popup.source", defaults.popup.source, POPUP_SOURCE_RULES)
167+
end
132168
end
133169

134-
local history = shared.as_table(popup.history)
135-
if history then
136-
shared.apply_rules(history, "ui.popup.history", defaults.popup.history, POPUP_HISTORY_RULES)
170+
if popup.history ~= nil then
171+
local history = shared.ensure_table_field(popup, "history", "ui.popup.history", defaults.popup.history)
172+
if history then
173+
shared.apply_rules(history, "ui.popup.history", defaults.popup.history, POPUP_HISTORY_RULES)
174+
end
175+
end
176+
177+
if popup.auto_close ~= nil then
178+
local auto_close = shared.ensure_table_field(popup, "auto_close", "ui.popup.auto_close", defaults.popup.auto_close)
179+
if auto_close then
180+
shared.apply_rules(auto_close, "ui.popup.auto_close", defaults.popup.auto_close, POPUP_AUTO_CLOSE_RULES)
181+
end
137182
end
138183
end
139184

140185
---@param ui table
141186
---@param defaults PeekstackConfigUI
142187
local function validate_path(ui, defaults)
143-
local path = shared.as_table(ui.path)
188+
if ui.path == nil then
189+
return
190+
end
191+
local path = shared.ensure_table_field(ui, "path", "ui.path", defaults.path)
144192
if path then
145193
shared.apply_rules(path, "ui.path", defaults.path, UI_PATH_RULES)
146194
end
@@ -162,25 +210,34 @@ end
162210
---@param ui table
163211
---@param defaults PeekstackConfigUI
164212
local function validate_preview(ui, defaults)
165-
local inline_preview = shared.as_table(ui.inline_preview)
166-
if inline_preview then
167-
shared.apply_rules(inline_preview, "ui.inline_preview", defaults.inline_preview, INLINE_PREVIEW_RULES)
213+
if ui.inline_preview ~= nil then
214+
local inline_preview = shared.ensure_table_field(ui, "inline_preview", "ui.inline_preview", defaults.inline_preview)
215+
if inline_preview then
216+
shared.apply_rules(inline_preview, "ui.inline_preview", defaults.inline_preview, INLINE_PREVIEW_RULES)
217+
end
168218
end
169219

170-
local quick_peek = shared.as_table(ui.quick_peek)
171-
if quick_peek then
172-
shared.apply_rules(quick_peek, "ui.quick_peek", defaults.quick_peek, QUICK_PEEK_RULES)
220+
if ui.quick_peek ~= nil then
221+
local quick_peek = shared.ensure_table_field(ui, "quick_peek", "ui.quick_peek", defaults.quick_peek)
222+
if quick_peek then
223+
shared.apply_rules(quick_peek, "ui.quick_peek", defaults.quick_peek, QUICK_PEEK_RULES)
224+
end
173225
end
174226
end
175227

176228
---@param ui table
177229
---@param defaults PeekstackConfigTitle
178230
local function validate_title(ui, defaults)
179-
local title = shared.as_table(ui.title)
231+
if ui.title == nil then
232+
return
233+
end
234+
local title = shared.ensure_table_field(ui, "title", "ui.title", defaults)
180235
if not title then
181236
return
182237
end
183238

239+
shared.apply_rules(title, "ui.title", defaults, TITLE_RULES)
240+
184241
if title.icons ~= nil and type(title.icons) ~= "table" then
185242
notify.warn("ui.title.icons must be a table, got " .. type(title.icons) .. ". Falling back to defaults")
186243
title.icons = vim.deepcopy(defaults.icons)
@@ -195,6 +252,13 @@ local function validate_title(ui, defaults)
195252
icons.map = vim.deepcopy(defaults.icons.map)
196253
end
197254
end
255+
256+
if title.context ~= nil then
257+
local context = shared.ensure_table_field(title, "context", "ui.title.context", defaults.context)
258+
if context then
259+
shared.apply_rules(context, "ui.title.context", defaults.context, TITLE_CONTEXT_RULES)
260+
end
261+
end
198262
end
199263

200264
---@param ui table
@@ -248,6 +312,20 @@ function M.validate(cfg, defaults)
248312
validate_preview(ui, defaults.ui)
249313
validate_title(ui, defaults.ui.title)
250314
validate_layout(ui, defaults.ui)
315+
316+
if ui.feedback ~= nil then
317+
local feedback = shared.ensure_table_field(ui, "feedback", "ui.feedback", defaults.ui.feedback)
318+
if feedback then
319+
shared.apply_rules(feedback, "ui.feedback", defaults.ui.feedback, FEEDBACK_RULES)
320+
end
321+
end
322+
323+
if ui.promote ~= nil then
324+
local promote = shared.ensure_table_field(ui, "promote", "ui.promote", defaults.ui.promote)
325+
if promote then
326+
shared.apply_rules(promote, "ui.promote", defaults.ui.promote, PROMOTE_RULES)
327+
end
328+
end
251329
end
252330

253331
return M

lua/peekstack/core/history.lua

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ end
1919
---@return PeekstackHistoryEntry
2020
function M.build_entry(item, idx)
2121
return {
22+
popup_id = item.id,
2223
location = item.location,
2324
title = item.title,
2425
title_chunks = item.title_chunks,
@@ -52,16 +53,77 @@ local function emit_popup_event(event, popup_model, root_winid)
5253
user_events.emit(event, user_events.build_popup_data(popup_model, root_winid))
5354
end
5455

56+
---Check whether a popup with the given id exists in the stack.
57+
---@param stack PeekstackStackModel
58+
---@param popup_id integer
59+
---@return boolean
60+
local function popup_exists_in_stack(stack, popup_id)
61+
for _, p in ipairs(stack.popups) do
62+
if p.id == popup_id then
63+
return true
64+
end
65+
end
66+
return false
67+
end
68+
69+
---Resolve parent_popup_id for a history entry being restored.
70+
---Uses id_remap (for bulk restore) and verifies the target exists.
71+
---@param stack PeekstackStackModel
72+
---@param entry PeekstackHistoryEntry
73+
---@param id_remap? table<integer, integer>
74+
---@return integer?
75+
local function resolve_parent_id(stack, entry, id_remap)
76+
local parent_id = entry.parent_popup_id
77+
if not parent_id then
78+
return nil
79+
end
80+
if id_remap and id_remap[parent_id] then
81+
parent_id = id_remap[parent_id]
82+
end
83+
if popup_exists_in_stack(stack, parent_id) then
84+
return parent_id
85+
end
86+
return nil
87+
end
88+
89+
---Return the stack-level id remap table, creating it if needed.
90+
---@param stack PeekstackStackModel
91+
---@return table<integer, integer>
92+
local function ensure_id_remap(stack)
93+
if not stack._id_remap then
94+
stack._id_remap = {}
95+
end
96+
return stack._id_remap
97+
end
98+
99+
---Record the mapping from old popup_id to new model id.
100+
---@param stack PeekstackStackModel
101+
---@param entry PeekstackHistoryEntry
102+
---@param model PeekstackPopupModel
103+
local function record_remap(stack, entry, model)
104+
if entry.popup_id then
105+
ensure_id_remap(stack)[entry.popup_id] = model.id
106+
end
107+
end
108+
55109
---Restore a history entry into the stack.
56110
---@param stack PeekstackStackModel
57111
---@param entry PeekstackHistoryEntry
112+
---@param id_remap? table<integer, integer> extra old popup id -> new popup id mapping
58113
---@return PeekstackPopupModel?
59-
function M.restore_entry(stack, entry)
114+
function M.restore_entry(stack, entry, id_remap)
60115
deps()
116+
-- Merge stack-level remap with any caller-provided remap.
117+
local merged = ensure_id_remap(stack)
118+
if id_remap then
119+
for k, v in pairs(id_remap) do
120+
merged[k] = v
121+
end
122+
end
61123
local create_opts = {
62124
buffer_mode = entry.buffer_mode or "copy",
63125
origin_winid = stack.root_winid,
64-
parent_popup_id = entry.parent_popup_id,
126+
parent_popup_id = resolve_parent_id(stack, entry, merged),
65127
}
66128
-- Only pass title override for user-renamed popups (no title_chunks).
67129
-- Auto-generated titles are regenerated by build_title() to preserve
@@ -112,6 +174,7 @@ function M.restore_last(stack)
112174
if not restored then
113175
return nil
114176
end
177+
record_remap(stack, entry, restored)
115178
table.remove(stack.history)
116179
return restored
117180
end
@@ -122,10 +185,15 @@ end
122185
function M.restore_all(stack)
123186
local restored = {}
124187
local remaining = {}
188+
---@type table<integer, integer>
189+
local id_remap = {}
125190
while #stack.history > 0 do
126191
local entry = table.remove(stack.history)
127-
local model = M.restore_entry(stack, entry)
128-
if model then
192+
local model = M.restore_entry(stack, entry, id_remap)
193+
if model and entry.popup_id then
194+
id_remap[entry.popup_id] = model.id
195+
table.insert(restored, model)
196+
elseif model then
129197
table.insert(restored, model)
130198
else
131199
table.insert(remaining, 1, entry)
@@ -148,6 +216,7 @@ function M.restore_from_history(stack, idx)
148216
if not restored then
149217
return nil
150218
end
219+
record_remap(stack, entry, restored)
151220
table.remove(stack.history, idx)
152221
return restored
153222
end
@@ -163,6 +232,7 @@ end
163232
---@param stack PeekstackStackModel
164233
function M.clear(stack)
165234
stack.history = {}
235+
stack._id_remap = nil
166236
end
167237

168238
return M

0 commit comments

Comments
 (0)