current commit
261101.1 · fix(git): fix fish preview quoting for special char paths
---@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