Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions lua/peekstack/config/validate/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,32 @@ local picker = require("peekstack.config.validate.rules.picker")
local providers = require("peekstack.config.validate.rules.providers")
local persist = require("peekstack.config.validate.rules.persist")
local unknown = require("peekstack.config.validate.unknown")
local notify = require("peekstack.util.notify")

local M = {}

---Restore top-level sections (`ui`, `picker`, ...) that the user replaced with
---a non-table value. Field validators only descend into tables, so without
---this `setup({ ui = false })` would leave `cfg.ui == false` and crash the
---first consumer that indexes it.
---@param cfg table
---@param defaults PeekstackConfig
local function restore_sections(cfg, defaults)
for key, default in pairs(defaults) do
local value = cfg[key]
if type(default) == "table" and value ~= nil and type(value) ~= "table" then
notify.warn(string.format("%s must be a table, got %s. Falling back to defaults", key, type(value)))
cfg[key] = vim.deepcopy(default)
end
end
end

---@param cfg table
---@param defaults PeekstackConfig
function M.run(cfg, defaults)
-- Detect unknown keys first, before field validators can replace invalid
-- subtrees with defaults (which would hide sibling typos).
restore_sections(cfg, defaults)
unknown.detect(cfg, defaults)
ui.validate(cfg, defaults)
picker.validate(cfg, defaults)
Expand Down
23 changes: 20 additions & 3 deletions lua/peekstack/config/validate/shared.lua
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ function M.validate_ratio(path, value, default)
return value
end

---Whether `name` is an autocmd event Neovim accepts in nvim_create_autocmd().
---Unknown names would make the autocmd registration throw at setup time.
---@param name string
---@return boolean
function M.is_known_event(name)
return vim.fn.exists("##" .. name) == 1
end

---@param path string
---@param value any
---@param default string[]
Expand All @@ -84,17 +92,26 @@ function M.sanitize_event_list(path, value, default)
---@type string[]
local events = {}
local invalid_count = 0
---@type string[]
local unknown_events = {}
for _, event in ipairs(value) do
if type(event) == "string" and event ~= "" then
events[#events + 1] = event
else
if type(event) ~= "string" or event == "" then
invalid_count = invalid_count + 1
elseif not M.is_known_event(event) then
unknown_events[#unknown_events + 1] = event
else
events[#events + 1] = event
end
end

if invalid_count > 0 then
notify.warn(string.format("%s contains %d invalid entries. Ignoring invalid values", path, invalid_count))
end
if #unknown_events > 0 then
notify.warn(
string.format("%s contains unknown autocmd events: %s. Ignoring them", path, table.concat(unknown_events, ", "))
)
end

if #events == 0 then
notify.warn(string.format("%s must contain at least one valid event. Falling back to defaults", path))
Expand Down
43 changes: 43 additions & 0 deletions lua/peekstack/persist/migrate.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,39 @@ local function migrate_v1_to_v2(items)
}
end

---Coerce a single persisted session into the shape the rest of the persist
---layer assumes (`items` list + `meta` timestamps). Returns nil when the entry
---cannot be a session at all so the caller can drop it.
---@param session any
---@return PeekstackSession?
local function normalize_session(session)
if type(session) ~= "table" then
return nil
end

-- items must be a list; a dictionary would be silently skipped by ipairs.
if type(session.items) ~= "table" or not vim.islist(session.items) then
session.items = {}
end

-- meta must be a record; adding timestamps to a list would produce a
-- mixed table that cannot be re-encoded as JSON.
local meta = session.meta
if type(meta) ~= "table" or (next(meta) ~= nil and vim.islist(meta)) then
meta = {}
end
local now = current_time()
if type(meta.created_at) ~= "number" then
meta.created_at = now
end
if type(meta.updated_at) ~= "number" then
meta.updated_at = meta.created_at
end
session.meta = meta

return session
end

---Ensure data is in the correct format (migration helper)
---@param data any
---@return PeekstackStoreData
Expand All @@ -40,6 +73,16 @@ function M.ensure(data)
if type(data.sessions) ~= "table" then
data.sessions = {}
end
-- A hand-edited or partially written file may hold malformed entries;
-- normalize them here so consumers never index into a non-table session.
local sessions = {}
for name, session in pairs(data.sessions) do
local normalized = type(name) == "string" and normalize_session(session) or nil
if normalized then
sessions[name] = normalized
end
end
data.sessions = sessions
return data
end

Expand Down
84 changes: 83 additions & 1 deletion lua/peekstack/persist/orchestrator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,86 @@ function M.write_async(data, on_done)
})
end

---@class PeekstackPersistUpdate
---@field mutate fun(data: PeekstackStoreData): boolean
---@field on_done? fun(success: boolean)

---@type PeekstackPersistUpdate[]
local update_queue = {}
local update_running = false

local function run_next_update()
local update = table.remove(update_queue, 1)
if not update then
update_running = false
return
end
update_running = true

local function finish(success)
if update.on_done then
-- A throwing callback must not wedge the queue with update_running=true.
local ok, err = pcall(update.on_done, success)
if not ok then
notify.warn("Session update callback failed: " .. tostring(err))
end
end
run_next_update()
end

M.read_async(function(data)
local ok, keep = pcall(update.mutate, data)
if not ok then
notify.warn("Failed to update session data: " .. tostring(keep))
finish(false)
return
end
if not keep then
finish(false)
return
end
M.write_async(data, finish)
end)
end

---Asynchronously read, mutate and write back store data.
---Updates are serialized: each one reads the file only after the previous
---write finished, so overlapping save/delete/rename calls cannot clobber each
---other with a stale snapshot (read-modify-write lost update).
---`mutate` returns true to persist the change or false to abort without
---writing; `on_done` receives whether the data was written successfully.
---@param mutate fun(data: PeekstackStoreData): boolean
---@param on_done? fun(success: boolean)
function M.update_async(mutate, on_done)
update_queue[#update_queue + 1] = { mutate = mutate, on_done = on_done }
if not update_running then
run_next_update()
end
end

---Upper bound for draining in-flight async updates before a sync update.
local UPDATE_SYNC_DRAIN_MS = 1000

---Synchronously read, mutate and write back store data.
---Drains queued async updates first (bounded by UPDATE_SYNC_DRAIN_MS) so a
---sync save issued while an async save is mid-flight does not race it; the
---sync write itself then runs atomically from Lua's point of view.
---@param mutate fun(data: PeekstackStoreData): boolean
---@return boolean success whether the data was written
function M.update_sync(mutate)
if update_running then
vim.wait(UPDATE_SYNC_DRAIN_MS, function()
return not update_running
end, 10)
end

local data = M.read_sync()
if not mutate(data) then
return false
end
return M.write_sync(data)
end

---Synchronously write data; on success refresh cache.
---@param data PeekstackStoreData
---@return boolean
Expand All @@ -97,9 +177,11 @@ function M.write_sync(data)
return success
end

---Reset the in-memory session cache.
---Reset the in-memory session cache and drop queued updates.
function M.reset_cache()
cache.reset()
update_queue = {}
update_running = false
end

---@return boolean
Expand Down
86 changes: 46 additions & 40 deletions lua/peekstack/persist/service.lua
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,21 @@ function M.save_current(name, opts)
local items = sessions.collect_items(opts and opts.root_winid or nil)

if sync then
local data = sessions.upsert(orchestrator.read_sync(), resolved_name, items)
local success = orchestrator.write_sync(data)
local success = orchestrator.update_sync(function(data)
sessions.upsert(data, resolved_name, items)
return true
end)
notify_save_result(success, resolved_name, items, silent)
finish(success)
return
end

orchestrator.read_async(function(read_data)
local data = sessions.upsert(read_data, resolved_name, items)
orchestrator.write_async(data, function(success)
notify_save_result(success, resolved_name, items, silent)
finish(success)
end)
orchestrator.update_async(function(data)
sessions.upsert(data, resolved_name, items)
return true
end, function(success)
notify_save_result(success, resolved_name, items, silent)
finish(success)
end)
end

Expand Down Expand Up @@ -220,22 +222,25 @@ function M.delete_session(name)
return
end

orchestrator.read_async(function(data)
if not sessions.delete(data, name) then
local found = false
orchestrator.update_async(function(data)
found = sessions.delete(data, name)
if not found then
notify.warn("Session not found: " .. name)
end
return found
end, function(success)
if not found then
return
end

orchestrator.write_async(data, function(success)
if success then
notify.info("Session deleted: " .. name)
user_events.emit("PeekstackDeleteSession", {
session = name,
})
else
notify.warn("Failed to delete session: " .. name)
end
end)
if success then
notify.info("Session deleted: " .. name)
user_events.emit("PeekstackDeleteSession", {
session = name,
})
else
notify.warn("Failed to delete session: " .. name)
end
end)
end

Expand All @@ -252,28 +257,29 @@ function M.rename_session(from, to)
return
end

orchestrator.read_async(function(data)
local renamed = false
orchestrator.update_async(function(data)
local result = sessions.rename(data, from, to)
if not result.ok then
if result.err == "missing" then
notify.warn("Session not found: " .. from)
elseif result.err == "exists" then
notify.warn("Target session already exists: " .. to)
end
renamed = result.ok
if result.err == "missing" then
notify.warn("Session not found: " .. from)
elseif result.err == "exists" then
notify.warn("Target session already exists: " .. to)
end
return renamed
end, function(success)
if not renamed then
return
end

orchestrator.write_async(data, function(success)
if success then
notify.info("Session renamed: " .. from .. " -> " .. to)
user_events.emit("PeekstackRenameSession", {
from = from,
to = to,
})
else
notify.warn("Failed to rename session: " .. from .. " -> " .. to)
end
end)
if success then
notify.info("Session renamed: " .. from .. " -> " .. to)
user_events.emit("PeekstackRenameSession", {
from = from,
to = to,
})
else
notify.warn("Failed to rename session: " .. from .. " -> " .. to)
end
end)
end

Expand Down
Loading