feat(picker): added undo picker to navigate the undo tree. Closes #638

This commit is contained in:
Folke Lemaitre 2025-01-19 21:54:18 +01:00
parent b0d3266985
commit 5c45f1c193
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
5 changed files with 189 additions and 14 deletions

View file

@ -34,6 +34,7 @@ local M = {}
---@field end_pos? {[1]:number, [2]:number}
---@field highlights? snacks.picker.Highlight[][]
---@field preview? snacks.picker.Item.preview
---@field resolve? fun(item:snacks.picker.Item)
---@class snacks.picker.finder.Item: snacks.picker.Item
---@field idx? number
@ -253,6 +254,9 @@ local defaults = {
middle = "├╴",
last = "└╴",
},
undo = {
saved = "",
},
ui = {
live = "󰐰 ",
hidden = "h",

View file

@ -42,6 +42,11 @@ Snacks.util.set_hl({
BufNr = "Number",
BufFlags = "NonText",
KeymapRhs = "NonText",
Time = "Special",
UndoAdded = "Added",
UndoRemoved = "Removed",
UndoCurrent = "@variable.builtin",
UndoSaved = "Special",
GitCommit = "@variable.builtin",
GitBreaking = "Error",
GitBranch = "Title",

View file

@ -571,6 +571,13 @@ M.spelling = {
confirm = "item_action",
}
M.undo = {
finder = "vim_undo",
format = "undo",
preview = "preview",
confirm = "item_action",
}
-- Open a project from zoxide
M.zoxide = {
finder = "files_zoxide",

View file

@ -151,24 +151,59 @@ function M.git_branch(item, picker)
return ret
end
function M.indent(item, picker)
local ret = {} ---@type snacks.picker.Highlight[]
local indents = picker.opts.icons.indent
local indent = {} ---@type string[]
local node = item
while node and node.depth > 0 do
local is_last, icon = node.last, ""
if node ~= item then
icon = is_last and " " or indents.vertical
else
icon = is_last and indents.last or indents.middle
end
table.insert(indent, 1, icon)
node = node.parent
end
ret[#ret + 1] = { table.concat(indent), "SnacksPickerIndent" }
return ret
end
function M.undo(item, picker)
local ret = {} ---@type snacks.picker.Highlight[]
local entry = item.item ---@type vim.fn.undotree.entry
local a = Snacks.picker.util.align
if item.current then
ret[#ret + 1] = { a("", 2), "SnacksPickerUndoCurrent" }
else
ret[#ret + 1] = { a("", 2) }
end
vim.list_extend(ret, M.indent(item, picker))
ret[#ret + 1] = { a(tostring(entry.seq), 4), "SnacksPickerIdx" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { a(Snacks.picker.util.reltime(entry.time), 13), "SnacksPickerTime" }
ret[#ret + 1] = { " " }
local function num(v, prefix)
v = v or 0
return a((v and v > 0 and prefix .. v or ""), 4)
end
ret[#ret + 1] = { num(item.added, "+"), "SnacksPickerUndoAdded" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { num(item.removed, "-"), "SnacksPickerUndoRemoved" }
if entry.save then
ret[#ret + 1] = { " " }
ret[#ret + 1] = { a(picker.opts.icons.undo.saved, 2), "SnacksPickerUndoSaved" }
end
return ret
end
function M.lsp_symbol(item, picker)
local opts = picker.opts --[[@as snacks.picker.lsp.symbols.Config]]
local ret = {} ---@type snacks.picker.Highlight[]
if item.hierarchy and not opts.workspace then
local indents = picker.opts.icons.indent
local indent = {} ---@type string[]
local node = item
while node and node.depth > 0 do
local is_last, icon = node.last, ""
if node ~= item then
icon = is_last and " " or indents.vertical
else
icon = is_last and indents.last or indents.middle
end
table.insert(indent, 1, icon)
node = node.parent
end
ret[#ret + 1] = { table.concat(indent), "SnacksPickerIndent" }
vim.list_extend(ret, M.indent(item, picker))
end
local kind = item.kind or "Unknown" ---@type string
local kind_hl = "SnacksPickerIcon" .. kind

View file

@ -323,4 +323,128 @@ function M.spelling()
return items
end
---@type snacks.picker.finder
function M.undo()
local tree = vim.fn.undotree()
local buf = vim.api.nvim_get_current_buf()
local win = vim.api.nvim_get_current_win()
local view = vim.api.nvim_win_call(win, vim.fn.winsaveview)
local items = {} ---@type snacks.picker.finder.Item[]
---@param entries vim.fn.undotree.entry[]
---@param parent? snacks.picker.finder.Item
local function add(entries, parent)
entries = entries or {}
table.sort(entries, function(a, b)
return a.seq > b.seq
end)
for e, entry in ipairs(entries) do
local file = vim.api.nvim_buf_get_name(buf)
---@param item snacks.picker.finder.Item
local function resolve(item)
---@type string[], string[]
local before, after = {}, {}
local ei = vim.o.eventignore
vim.o.eventignore = "all"
vim.api.nvim_buf_call(buf, function()
-- state after the undo
vim.cmd("noautocmd silent undo " .. entry.seq)
after = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
-- state before the undo
vim.cmd("noautocmd silent undo")
before = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
end)
vim.o.eventignore = ei
local diff = vim.diff(table.concat(before, "\n"), table.concat(after, "\n"), { ctxlen = 4 }) --[[@as string]]
local changes = {} ---@type string[]
local added, removed = 0, 0
for _, line in ipairs(vim.split(diff, "\n")) do
if line:sub(1, 1) == "+" then
added = added + 1
changes[#changes + 1] = line:sub(2)
elseif line:sub(1, 1) == "-" then
removed = removed + 1
changes[#changes + 1] = line:sub(2)
end
end
diff = Snacks.picker.util.tpl(
"diff --git a/{file} b/{file}\n--- {file}\n+++ {file}\n{diff}",
{ file = vim.fn.fnamemodify(file, ":."), diff = diff }
)
item.text = table.concat(changes, " ")
item.added = added
item.removed = removed
item.preview = {
text = diff,
ft = "diff",
}
end
local item = {
buf = buf,
resolve = resolve,
file = file,
item = entry,
current = entry.seq == tree.seq_cur,
last = e == #entries,
parent = parent,
depth = parent and parent.depth + 1 or 1,
action = function()
vim.api.nvim_buf_call(buf, function()
vim.cmd("undo " .. entry.seq)
end)
end,
}
items[#items + 1] = item
add(entry.alt, item)
end
end
add(tree.entries)
-- disable folding to prevent fold re-calculations
local foldmethod = vim.wo[win].foldmethod
vim.wo[win].foldmethod = "manual"
vim.b[buf].snacks_scroll = false
local function restore()
vim.api.nvim_buf_call(buf, function()
-- reset to the original state
vim.cmd("noautocmd silent undo " .. tree.seq_cur)
vim.wo[win].foldmethod = foldmethod
end)
vim.api.nvim_win_call(win, function()
vim.fn.winrestview(view)
end)
vim.b[buf].snacks_scroll = nil
end
-- Resolve the items in batches to prevent blocking the UI
---@param cb async fun(item: snacks.picker.finder.Item)
---@param task snacks.picker.Async
---@async
return function(cb, task)
task:on("abort", vim.schedule_wrap(restore))
while #items > 0 do
vim.schedule(function()
local count = 0
while #items > 0 and count < 5 do
count = count + 1
local item = table.remove(items, 1)
Snacks.picker.util.resolve(item)
cb(item)
end
if #items == 0 then
restore()
end
task:resume()
end)
task:suspend()
end
end
end
return M