current commit
260920.1 · fix(oldfiles): async fs_stat with per-file stat_timeout (fix #2793)
---@diagnostic disable-next-line: deprecated
local uv = vim.uv or vim.loop
local core = require "fzf-lua.core"
local utils = require "fzf-lua.utils"
local config = require "fzf-lua.config"
local make_entry = require "fzf-lua.make_entry"

local M = {}

---@param opts fzf-lua.config.Oldfiles|{}?
---@param globals string|(fzf-lua.Config|{})?
---@return thread?, string?, table?
M.oldfiles = function(opts, globals)
  ---@type fzf-lua.config.Oldfiles
  opts = config.normalize_opts(opts, globals or "oldfiles")
  if not opts then return end

  -- cwd implies we want `cwd_only=true`
  if opts.cwd and opts.cwd_only == nil then
    opts.cwd_only = true
  end

  -- `fs_stat` can hang indefinitely on broken files/links, stale network
  -- mounts, etc. When running inside the contents coroutine use the async
  -- variant with a per-file timeout so such files are skipped instead of
  -- hanging the picker, disable by setting `stat_timeout=0` (#2793)
  local stat_fn = not opts.stat_file and function(_) return true end
      or type(opts.stat_file) == "function" and opts.stat_file
      or function(file, co)
        -- outside of a coroutine context (e.g. the header line) we have
        -- no choice but to use the blocking sync variant
        if not co or not opts.stat_timeout or opts.stat_timeout <= 0 then
          local stat = uv.fs_stat(file)
          return (not utils.path_is_directory(file, stat)
            -- FIFO blocks `fs_open` indefinitely (#908)
            and not utils.file_is_fifo(file, stat)
            and utils.file_is_readable(file))
        end
        -- schedule the resume as the fs/timer callbacks run in fast-event
        -- context where most `vim.api` calls in the loop are not allowed
        local resume = function(...)
          local argv = { n = select("#", ...), ... }
          vim.schedule(function()
            if coroutine.status(co) == "suspended" then
              coroutine.resume(co, unpack(argv, 1, argv.n))
            end
          end)
        end
        utils.fs_stat_async(file, opts.stat_timeout, function(stat)
          if not stat
              or utils.path_is_directory(file, stat)
              -- FIFO blocks `fs_open` indefinitely (#908)
              or utils.file_is_fifo(file, stat)
          then
            return resume(false)
          end
          -- a stale mount can hang `fs_open` as well (#2793)
          utils.file_is_readable_async(file, opts.stat_timeout, resume)
        end)
        -- resumed with the result by the callbacks above
        return coroutine.yield()
      end

  ---@param co thread? contents coroutine, enables async stat (#2793)
  local sorted_named_buffers = function(co)
    local bufnrs = {}
    -- capture the current buffer once, the context can change while
    -- waiting on async stat results (e.g. fzf's window opens)
    local curr_buf = utils.CTX().bufnr
    for _, bufnr in ipairs(require("fzf-lua.providers.buffers").list_bufs_sorted()) do
      local file = vim.api.nvim_buf_get_name(bufnr)
      local fs_stat = #file > 0 and stat_fn(file, co)
      if fs_stat then
        table.insert(bufnrs, { bufnr = bufnr, file = file, curbuf = bufnr == curr_buf })
      end
    end
    return bufnrs
  end

  -- current buffer as a header line
  -- since we exclude unnamed/terminal/non-existent files
  -- only set header line if first entry matches current buffer
  local header_line = function()
    if opts.include_current_session and not opts.ignore_current_buffer then
      local buflist = sorted_named_buffers()
      if buflist[1] and buflist[1].bufnr == utils.CTX().bufnr then
        return true
      end
    end
  end

  if header_line() then utils.map_set(opts, "fzf_opts.--header-lines", 1) end

  -- NOTE: fzf does not yet support "change-header-lines" transform
  -- https://github.com/junegunn/fzf/issues/4659
  -- opts._resume_reload = function()
  --   return string.format("change-header-lines(%d)", header_line() and 1 or 0)
  -- end

  local contents = function(cb)
    -- run in a coroutine for async progress indication
    coroutine.wrap(function()
      local co = coroutine.running()
      ---@cast co thread

      local curr_buf = utils.CTX().bufnr
      local curr_file = vim.api.nvim_buf_get_name(curr_buf)
      local sess_map = {} -- dedup files from current buffers

      -- read the optional shada file
      if opts.shada_file then
        local file = vim.fs.normalize(opts.shada_file)
        if vim.uv.fs_stat(file) then
          vim.cmd("rshada! " .. vim.fn.fnameescape(file))
        else
          utils.warn("shada file '%s' does not exist, ignoring.", opts.shada_file)
        end
      end

      ---@diagnostic disable-next-line: redefined-local
      local function add_entry(x, co, force)
        x = make_entry.file(x,
          force and vim.tbl_deep_extend("force", {}, opts, { cwd_only = false }) or opts)
        if not x then return end
        cb(x, function(err)
          coroutine.resume(co)
          if err then
            -- close the pipe to fzf, this
            -- removes the loading indicator in fzf
            cb(nil)
          end
        end)
        coroutine.yield()
      end

      if opts.include_current_session then
        for _, buf in ipairs(sorted_named_buffers(co)) do
          if not opts.ignore_current_buffer or buf.bufnr ~= curr_buf then
            sess_map[buf.file] = true
            -- 3rd arg forces addition of current buffer with cwd_only
            add_entry(buf.file, co, buf.curbuf)
          end
        end
      end

      -- local start = os.time(); for _ = 1,10000,1 do
      for _, file in ipairs(vim.v.oldfiles) do
        local fs_stat = stat_fn(file, co)
        if fs_stat and file ~= curr_file and not sess_map[file] then
          add_entry(file, co)
        end
      end
      -- end; print("took", os.time()-start, "seconds.")

      -- done
      cb(nil)
    end)()
  end

  -- for 'file_ignore_patterns' to work on relative paths
  opts.cwd = opts.cwd or utils.cwd()
  return core.fzf_exec(contents, opts)
end

---@param opts fzf-lua.config.History|{}?
---@return thread?, string?, table?
M.history = function(opts)
  return M.oldfiles(opts, "history")
end

return M