---@diagnostic disable-next-line: deprecated
local uv = vim.uv or vim.loop
local core = require "fzf-lua.core"
local path = require "fzf-lua.path"
local utils = require "fzf-lua.utils"
local shell = require "fzf-lua.shell"
local config = require "fzf-lua.config"
local native = require("fzf-lua.previewer.fzf")
local builtin = require("fzf-lua.previewer.builtin")
local M = {}
-- neovim/runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua
--- @param buf integer
--- @return vim.fn.undotree.entry[]
--- @return integer
local function get_undotree_entries(buf)
local undotree = vim.fn.undotree(buf)
local entries = undotree.entries
--Maybe: `:undo 0` and then `undotree` to get seq 0 time
table.insert(entries, 1, { seq = 0, time = -1 })
return entries, undotree.seq_cur
end
--- @param ent vim.fn.undotree.entry[]
--- @param _tree vim.undotree.tree?
--- @param _last integer?
--- @return vim.undotree.tree
local function treefy(ent, _tree, _last)
local tree = _tree or {}
local last = _last or nil
for idx, v in ipairs(ent) do
local seq = v.seq
if last then
table.insert(tree[last].child, seq)
else
assert(idx == 1 and not _tree)
end
tree[seq] = { child = {}, time = v.time }
if v.alt then
assert(last)
treefy(v.alt, tree, last)
end
last = seq
end
return tree
end
--- Returns the relative time from a given time
--- as ... ago
---@param time number
---@return string
function reltime(time)
if time == -1 then
return "origin"
end
local delta = os.time() - time
local tpl = {
{ 1, 60, "just now", "just now" },
{ 60, 3600, "a minute ago", "%d minutes ago" },
{ 3600, 3600 * 24, "an hour ago", "%d hours ago" },
{ 3600 * 24, 3600 * 24 * 7, "yesterday", "%d days ago" },
{ 3600 * 24 * 7, 3600 * 24 * 7 * 4, "a week ago", "%d weeks ago" },
}
for _, v in ipairs(tpl) do
if delta < v[2] then
local value = math.floor(delta / v[1] + 0.5)
return value == 1 and v[3] or v[4]:format(value)
end
end
if os.date("%Y", time) == os.date("%Y") then
---@diagnostic disable-next-line: return-type-mismatch
return os.date("%b %d", time) ---@type string
end
---@diagnostic disable-next-line: return-type-mismatch
return os.date("%b %d, %Y", time) ---@type string
end
---@diagnostic disable-next-line: unused
--- @param time integer
--- @return string
local function undo_fmt_time(time)
if time == -1 then
return "origin"
end
local diff = os.time() - time
if diff >= 100 then
if diff < (60 * 60 * 12) then
return os.date("%H:%M:%S", time) --[[@as string]]
else
return os.date("%Y/%m/%d %H:%M:%S", time) --[[@as string]]
end
else
return ("%d second%s ago"):format(diff, diff == 1 and "" or "s")
end
end
---@diagnostic disable-next-line: duplicate-type
--- @class (partial) vim.undotree.tree.entry
--- @field traversed? boolean
--- @class (private) fzf-lua.providers.undotree.GraphLineBase
--- @field index integer
--- @field node_count integer
--- @class (private) fzf-lua.providers.undotree.GraphLineNode: fzf-lua.providers.undotree.GraphLineBase
--- @field kind 'node'|'remove'|'nochange_remove'
--- @field node integer
--- @class (private) fzf-lua.providers.undotree.GraphLineBranch: fzf-lua.providers.undotree.GraphLineBase
--- @field kind 'branch'
--- @field node integer[]
--- @class (private) fzf-lua.providers.undotree.GraphLineRemoveBranch: fzf-lua.providers.undotree.GraphLineBase
--- @field kind 'remove+branch'
--- @field node integer
--- @field index2 integer
--- @alias fzf-lua.providers.undotree.GraphLine
--- | fzf-lua.providers.undotree.GraphLineNode
--- | fzf-lua.providers.undotree.GraphLineBranch
--- | fzf-lua.providers.undotree.GraphLineRemoveBranch
--- Ported from neovim/runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua
--- @param tree vim.undotree.tree
--- @return fzf-lua.providers.undotree.GraphLine[]
local function tree_to_graph_lines(tree)
--- @type fzf-lua.providers.undotree.GraphLine[]
local graph_lines = {}
---@diagnostic disable-next-line: unnecessary-assert
assert(tree[0], "tree doesn't have 0-th node")
--- @type (integer[]|integer)[]
local nodes = { 0 }
while #nodes > 0 do
local minseq = math.huge
--- @type integer
local index
--- @type integer
local node_index
for k, v in ipairs(nodes) do
if type(v) == 'table' then
for i, j in ipairs(v) do
if j < minseq then
minseq = j
index = k
node_index = i
end
end
elseif v < minseq then
assert(type(v) == 'number')
minseq = v
index = k
end
end
local node = nodes[index]
--- @param kind 'node'|'remove'|'branch'|'nochange_remove'
local function add_graph_line(kind)
table.insert(graph_lines, { kind = kind, index = index, node_count = #nodes, node = node })
end
if type(node) == 'number' then
add_graph_line('node')
local child = tree[node].child
if #child == 0 then
if index ~= #nodes then
add_graph_line('remove')
else
add_graph_line('nochange_remove')
end
table.remove(nodes, index)
elseif #child == 1 then
nodes[index] = child[1]
else
nodes[index] = child
end
else
assert(type(node) == 'table')
add_graph_line('branch')
table.remove(nodes, index)
if #node == 2 then
---@diagnostic disable-next-line: param-type-mismatch
table.insert(nodes, index, math.min(unpack(node)))
---@diagnostic disable-next-line: param-type-mismatch
table.insert(nodes, index, math.max(unpack(node)))
elseif #node > 2 then
table.insert(nodes, index, node[node_index])
table.insert(nodes, index, node)
table.remove(node, node_index)
end
end
end
for k, v in ipairs(graph_lines) do
local next_line = graph_lines[k + 1]
if v.kind == 'remove' and next_line and next_line.kind == 'branch' then
v.kind = 'remove+branch'
v.index2 = next_line.index
table.remove(graph_lines, k + 1)
end
end
return graph_lines
end
---@param _opts { hls: { dir_part: string, buf_name: string, path_linenr: string } }
---@param tree vim.undotree.tree
---@param v fzf-lua.providers.undotree.GraphLine
---@param reverse boolean
---@return string?
local function format_graph_line(_opts, tree, v, reverse)
-- Use Unicode box-drawing glyphs instead of ASCII so the tree stays upright
-- even when the highlight group (e.g. Comment) is italicized.
local PIPE = "│"
local STAR = "●"
local SLASH = "╱"
local BACKSLASH = "╲"
if reverse then
SLASH, BACKSLASH = BACKSLASH, SLASH
end
if v.kind == 'node' then
local seq = v.node --[[@as integer]]
return (PIPE .. ' '):rep(v.index - 1)
.. STAR
.. (' ' .. PIPE):rep(v.node_count - v.index)
.. ' '
.. seq
.. ' ('
.. reltime(tree[seq].time)
.. ')'
elseif v.kind == 'remove' then
return (PIPE .. ' '):rep(v.index - 1) .. (' ' .. SLASH):rep(v.node_count - v.index)
elseif v.kind == 'branch' then
return (PIPE .. ' '):rep(v.index - 1)
.. PIPE .. BACKSLASH
.. (' ' .. BACKSLASH):rep(v.node_count - v.index)
elseif v.kind == 'remove+branch' then
local index2 = assert(v.index2)
if index2 < v.index then
return (PIPE .. ' '):rep(index2 - 1)
.. PIPE .. BACKSLASH
.. (' ' .. BACKSLASH):rep(v.index - index2 - 1)
.. ' '
.. (' ' .. PIPE):rep(v.node_count - v.index)
else
return (PIPE .. ' '):rep(v.index - 1)
.. (' ' .. SLASH):rep(index2 - v.index)
.. ' ' .. SLASH .. PIPE
.. (' ' .. PIPE):rep(v.node_count - index2 - 1)
end
elseif v.kind == 'nochange_remove' then
return nil
end
error('unreachable')
end
---@param opts { hls: { dir_part: string, buf_name: string, path_linenr: string } }
---@param cb fun(seq: integer?, line: string)
---@param tree vim.undotree.tree
---@param _curseq integer
---@param reverse boolean
local function draw_graph(opts, cb, tree, _curseq, reverse)
local graph_lines = tree_to_graph_lines(tree)
local hl_dir = utils.ansi_codes[opts.hls.dir_part]
local hl_seq = utils.ansi_codes[opts.hls.buf_name]
local hl_time = utils.ansi_codes[opts.hls.path_linenr]
for _, v in ipairs(graph_lines) do
(function()
local line = format_graph_line(opts, tree, v, reverse)
if not line then return end
if v.kind == 'node' then
local seq = v.node --[[@as integer]]
local graph_part, seq_num, time_str = line:match('^(.-) (%d+) %(([^)]+)%)$')
assert(graph_part and seq_num and time_str)
cb(seq, hl_dir(graph_part)
.. ' ' .. hl_seq(seq_num)
.. ' (' .. hl_time(time_str) .. ')')
else
cb(nil, hl_dir(line))
end
end)()
end
end
--- @param opts { hls: { dir_part: string, buf_name: string, path_linenr: string } }
--- @param cb function
--- @param tree vim.undotree.tree
--- @param nodes integer[]
--- @param reverse boolean
--- @param parents? integer[]
--- @param prefix? string
local function draw_tree(opts, cb, tree, nodes, reverse, parents, prefix)
if not nodes or #nodes == 0 then return end
local is_root = nodes[1] == 0
prefix = prefix or ""
parents = parents or {}
table.sort(nodes)
for i, n in ipairs(nodes) do
local v = tree[n]
local is_last = i == #nodes
---@diagnostic disable-next-line: undefined-field
assert(not v.traversed)
---@diagnostic disable-next-line: inject-field
v.traversed = true
-- TODO: IMHO the latter (flattened single) is nicer
-- local is_single = #parents == 1 and #nodes == 1
local is_single = #nodes == 1
local leaf = reverse and "┌" or "└"
local node = utils.ansi_codes[opts.hls.dir_part](
prefix .. ((is_root or is_single) and "" or is_last and leaf .. "── " or "├── "))
.. utils.ansi_codes[opts.hls.buf_name](tostring(n))
-- local w = 64 + string.len(node) - vim.fn.strwidth(node)
cb(n, string.format("%s\t\t%s", node,
utils.ansi_codes[opts.hls.path_linenr](reltime(v.time))))
draw_tree(opts, cb, tree, v.child, reverse, nodes,
prefix .. ((is_root or is_single) and "" or is_last and " " or "│ "))
end
end
---@param opts fzf-lua.config.Undotree|{}?
---@return thread?, string?, table?
M.undotree = function(opts)
---@type fzf-lua.config.Undotree
opts = config.normalize_opts(opts, "undotree")
if not opts then return end
local contents = function(cb)
coroutine.wrap(function()
local co = coroutine.running()
---@cast co thread
local entries, curseq = get_undotree_entries(utils.CTX().bufnr)
local tree = treefy(entries)
local count = 0
local function add_entry(seq, e)
count = count + 1
if seq == curseq then
opts.__locate_pos = count
end
cb(e, function(err)
coroutine.resume(co)
if err then cb(nil) end
end)
coroutine.yield()
end
local tree_style = opts.tree_style or "tree"
local reverse = utils.map_get(opts, "fzf_opts.--layout") == "default"
if tree_style == "graph" then
draw_graph(opts, add_entry, tree, curseq, reverse)
else
draw_tree(opts, add_entry, tree, { 0 }, reverse)
end
cb(nil) -- EOF
end)()
end
return core.fzf_exec(contents, opts)
end
--- @class fzf-lua.UndoInfo
--- @field buf integer buffer id
--- @field changedtick integer last change tick
--- @field tmp_buf integer tmp buffer id
--- @field tmp_file string tmp file path
--- @field tmp_undo string tmp undo file path
---
--- Credit to snacks.nvim:
--- copies the buffer to a temporary file and load the undo history.
--- This is done to prevent the current buffer from being modified,
--- also better for performance as it won't trigger LSP change tracking
---
--- @param buf integer buffer id
--- @return fzf-lua.UndoInfo
local function load_undo_buf(buf)
local info = M._undoinfo or {}
M._undoinfo = info
-- do nothing if buffer is already loaded and unchanged
local changedtick = vim.b[buf].changedtick
if info.buf == buf and info.changedtick == changedtick then
return info
end
-- always create a new tmp buffer
if info.tmp_buf and vim.api.nvim_buf_is_valid(info.tmp_buf) then
vim.api.nvim_buf_delete(info.tmp_buf, { force = true })
end
info.buf = buf
info.changedtick = changedtick
info.tmp_file = path.join({ path.parent(utils.tempname()), "fzf-lua-undo" })
info.tmp_undo = info.tmp_file .. ".undo"
info.tmp_buf = vim.fn.bufadd(info.tmp_file)
vim.bo[info.tmp_buf].swapfile = false
vim.fn.writefile(vim.api.nvim_buf_get_lines(buf, 0, -1, false), info.tmp_file)
vim.fn.bufload(info.tmp_buf)
vim.api.nvim_buf_call(buf, function()
vim.cmd("silent wundo! " .. info.tmp_undo)
end)
vim.api.nvim_buf_call(info.tmp_buf, function()
---@diagnostic disable-next-line: param-type-mismatch
pcall(vim.cmd, "silent rundo " .. info.tmp_undo)
end)
return info
end
local function unload_undo_buf()
local info = M._undoinfo
if not info then return end
assert(info.tmp_buf)
assert(info.tmp_file)
assert(info.tmp_undo)
if vim.api.nvim_buf_is_valid(info.tmp_buf) then
vim.api.nvim_buf_delete(info.tmp_buf, { force = true })
end
if uv.fs_stat(info.tmp_file) then vim.fn.delete(info.tmp_file) end
if uv.fs_stat(info.tmp_undo) then vim.fn.delete(info.tmp_undo) end
M._undoinfo = nil
end
--- @param buf integer buffer id
--- @param seq number
--- @param diff_opts? vim.text.diff.Opts
--- @return string[], integer
local function undo_diff(buf, seq, diff_opts)
local info = load_undo_buf(buf)
local file = vim.api.nvim_buf_get_name(buf)
---@diagnostic disable-next-line: deprecated
local diff_fn = vim.text and vim.text.diff or vim.diff
---@type string[], string[]
local before, after = {}, {}
utils.eventignore(function()
vim.api.nvim_buf_call(info.tmp_buf, function()
-- state after the undo
vim.cmd("noautocmd silent undo " .. tostring(seq))
after = vim.api.nvim_buf_get_lines(0, 0, -1, false)
-- state before the undo
vim.cmd("noautocmd silent undo")
before = vim.api.nvim_buf_get_lines(0, 0, -1, false)
end)
end)
local diff = diff_fn(table.concat(before, "\n") .. "\n",
table.concat(after, "\n") .. "\n",
diff_opts) --[[@as string]]
local lines = vim.split(diff, "\n")
table.remove(lines)
file = path.relative_to(file, utils.cwd())
table.insert(lines, 1, string.format("diff --git a/%s b/%s", file, file))
table.insert(lines, 2, string.format("--- a/%s", file))
table.insert(lines, 3, string.format("+++ b/%s", file))
return lines, info.tmp_buf
end
--- @class fzf-lua.previewer.Undotree : fzf-lua.previewer.Builtin,{}
--- @field super fzf-lua.previewer.Builtin,{}
--- @field diff_buf integer
--- @field diff_opts vim.text.diff.Opts
--- @field show_buf boolean? # show original buffer instead of diff
M.builtin = builtin.base:extend()
---@diagnostic disable-next-line: unused
---@return boolean
function M.builtin:should_clear_preview(_)
return false
end
function M.builtin:gen_winopts()
local winopts = {
wrap = self.win.preview_wrap,
cursorline = false,
}
return vim.tbl_extend("keep", winopts, self.winopts)
end
---@param o fzf-lua.config.UndotreePreviewer
---@param opts fzf-lua.config.Undotree
---@return fzf-lua.previewer.Undotree
function M.builtin:new(o, opts)
M.builtin.super.new(self, o, opts)
self.buf = utils.CTX().bufnr
self.file = path.relative_to(vim.api.nvim_buf_get_name(self.buf), utils.cwd())
self.diff_opts = o.diff_opts
self.show_buf = o.show_buf
return self
end
function M.builtin:close()
if self.diff_buf and vim.api.nvim_buf_is_valid(self.diff_buf) then
vim.api.nvim_buf_delete(self.diff_buf, { force = true })
end
unload_undo_buf()
M.builtin.super.close(self)
end
function M.builtin:toggle_undo_diff()
self.show_buf = not self.show_buf
self.win:redraw_preview()
end
function M.builtin:populate_preview_buf(entry_str)
local seq_str = utils.strip_ansi_coloring(entry_str):match("%d+")
if not seq_str then return end
local seq = assert(utils.tointeger(seq_str))
local lines, buf = undo_diff(self.buf, seq, self.diff_opts)
if seq > 0 and not self.show_buf then
if not self.diff_buf or not vim.api.nvim_buf_is_valid(self.diff_buf) then
self.diff_buf = self:get_tmp_buffer()
self.diff_buf = vim.api.nvim_create_buf(false, true)
vim.bo[self.diff_buf].modeline = true
vim.bo[self.diff_buf].modifiable = true
vim.bo[self.diff_buf].bufhidden = ""
vim.bo[self.diff_buf].ft = "diff"
end
vim.api.nvim_buf_set_lines(self.diff_buf, 0, -1, false, lines)
self:set_preview_buf(self.diff_buf, nil, true)
else
utils.eventignore(function()
vim.api.nvim_buf_call(buf, function()
vim.cmd("noautocmd silent undo " .. tostring(seq))
end)
end)
vim.bo[buf].ft = vim.bo[self.buf].ft
self:set_preview_buf(buf, nil, true)
end
self.win:update_preview_title(self.file)
self.win:update_preview_scrollbar()
end
---@class fzf-lua.previewer.UndotreeNative: fzf-lua.previewer.Fzf,{}
---@field super fzf-lua.previewer.Fzf
M.native = native.base:extend()
---@param o fzf-lua.config.UndotreePreviewer
---@param opts fzf-lua.config.Undotree
---@return fzf-lua.previewer.UndotreeNative
function M.native:new(o, opts)
M.native.super.new(self, o, opts)
setmetatable(self, M.native)
self.buf = utils.CTX().bufnr
local pager = opts.preview_pager == nil and o.pager or opts.preview_pager
if type(pager) == "function" then pager = pager(opts) end
---@cast pager string
local cmd = pager and pager:match("[^%s]+") or nil
if cmd and vim.fn.executable(cmd) == 1 then self.pager = pager end
self.diff_opts = o.diff_opts
return self
end
function M.native:close()
unload_undo_buf()
M.native.super.close(self)
end
function M.native:cmdline(o)
o = o or {}
local act = shell.stringify_data(function(entries, _, _)
if not entries[1] then return utils.shell_nop() end
local seq_str = utils.strip_ansi_coloring(entries[1]):match("%d+")
if not seq_str then return utils.shell_nop() end
local seq = assert(utils.tointeger(seq_str))
local lines = undo_diff(self.buf, seq, self.diff_opts)
return table.concat(lines, "\r\n")
end, self.opts, "{}")
if self.pager then
act = act .. " | " .. utils._if_win_normalize_vars(self.pager)
end
return act
end
-- Exposed for tests.
M._draw_tree = draw_tree
M._draw_graph = draw_graph
return M