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 path = require "fzf-lua.path"
local shell = require "fzf-lua.shell"
local utils = require "fzf-lua.utils"
local libuv = require "fzf-lua.libuv"
local Object = require "fzf-lua.class"

local Previewer = {}

---@class fzf-lua.previewer.Fzf: fzf-lua.Object,{}
---@field close function
Previewer.base = Object:extend()

-- Previewer base object
---@param o fzf-lua.config.Previewer
---@param opts fzf-lua.config.Resolved|{}
---@return fzf-lua.previewer.Fzf
function Previewer.base:new(o, opts)
  o = o or {}
  self.type = "cmd";
  self.cmd = o.cmd;
  if type(self.cmd) == "function" then
    self.cmd = self.cmd()
  end
  self.args = o.args or "";
  self.preview_offset = o.preview_offset
  self.opts = opts;
  return self
end

---@param opts fzf-lua.config.Resolved
---@return fzf-lua.config.Resolved
function Previewer.base:setup_opts(opts)
  -- Set the preview command line
  opts.preview = require("fzf-lua.previewer").normalize_spec(self:cmdline(), self.opts)
  opts.preview_offset = self:_preview_offset()
  opts.fzf_opts["--preview-window"] = self:preview_window()
  opts.fzf_opts["--delimiter"] = self:fzf_delimiter()
  return opts
end

---@diagnostic disable-next-line: unused
function Previewer.base:close()
end

---@diagnostic disable-next-line: unused
---@return fzf-lua.preview.spec
function Previewer.base:cmdline(_)
  return ""
end

---@diagnostic disable-next-line: unused
function Previewer.base:preview_window(_)
  return nil
end

function Previewer.base:_preview_offset(lnum)
  --[[
    #
    #   Explanation of the fzf preview offset options:
    #
    #   ~3    Top 3 lines as the fixed header
    #   +{2}  Base scroll offset extracted from the second field
    #   +3    Extra offset to compensate for the 3-line header
    #   /2    Put in the middle of the preview area
    #
    '--preview-window '~3:+{2}+3/2''
  ]]
  lnum = lnum or self.opts.line_field_index
  if lnum then
    return ("+%s-/2"):format(lnum)
  end
end

---@return string
function Previewer.base:fzf_delimiter()
  -- set delimiter to ':'
  -- entry format is 'file:line:col: text'
  local delim = self.opts.fzf_opts and self.opts.fzf_opts["--delimiter"]
  if not delim then
    delim = "[:]"
  elseif not delim:match(":") then
    if delim:match("%[.*%]") then
      delim = delim:gsub("%]", ":]")
    else
      -- remove surrounding quotes
      delim = delim:match("^'?(.*)'$?") or delim
      delim = "[" .. utils.rg_escape(delim):gsub("%]", "\\]") .. ":]"
    end
  end
  return delim
end

-- Generic shell command previewer
---@class fzf-lua.previewer.Cmd : fzf-lua.previewer.Fzf,{}
---@field super fzf-lua.previewer.Fzf,{}
Previewer.cmd = Previewer.base:extend()

function Previewer.cmd:new(o, opts)
  Previewer.cmd.super.new(self, o, opts)
  return self
end

---@diagnostic disable-next-line: unused
function Previewer.cmd:format_cmd(cmd, args, action, extra_args)
  return string.format([[%s %s %s "$(%s)"]],
    cmd, args or "", extra_args or "", action)
end

function Previewer.cmd:cmdline(o)
  o = o or {}
  o.action = o.action or self:action(o)
  return self:format_cmd(self.cmd, self.args, o.action)
end

function Previewer.cmd:action(o)
  o = o or {}
  local act = shell.stringify_data(function(items, _, _)
    if not items[1] then return utils.shell_nop() end
    local entry = path.entry_to_file(items[1], self.opts)
    return entry.bufname or entry.path
  end, self.opts, self.opts.field_index_expr or "{}")
  return act
end

-- Specialized bat previewer
---@class fzf-lua.previewer.Bat : fzf-lua.previewer.Cmd,{}
---@field super fzf-lua.previewer.Cmd,{}
Previewer.bat = Previewer.cmd:extend()

function Previewer.bat:new(o, opts)
  Previewer.bat.super.new(self, o, opts)
  self.theme = o.theme
  return self
end

function Previewer.bat:cmdline(o)
  o = o or {}
  o.action = o.action or self:action(o)
  local extra_args = ""
  if self.theme then
    extra_args = string.format([[ --theme="%s"]],
      type(self.theme) == "function" and tostring(self.theme()) or tostring(self.theme))
  end
  if self.opts.line_field_index then
    extra_args = extra_args .. string.format(" --highlight-line=%s", self.opts.line_field_index)
  end
  return self:format_cmd(self.cmd, self.args, o.action, extra_args)
end

-- Specialized head previewer
---@class fzf-lua.previewer.Head : fzf-lua.previewer.Cmd,{}
---@field super fzf-lua.previewer.Cmd,{}
Previewer.head = Previewer.cmd:extend()

function Previewer.head:new(o, opts)
  Previewer.head.super.new(self, o, opts)
  return self
end

function Previewer.head:cmdline(o)
  o = o or {}
  o.action = o.action or self:action(o)
  local lines = "--lines=-0"
  -- print all lines instead
  -- if self.opts.line_field_index then
  --   lines = string.format("--lines=%s", self.opts.line_field_index)
  -- end
  return self:format_cmd(self.cmd, self.args, o.action, lines)
end

-- new async_action from nvim-fzf
---@class fzf-lua.previewer.CmdAsync : fzf-lua.previewer.Cmd,{}
---@field super fzf-lua.previewer.Cmd,{}
Previewer.cmd_async = Previewer.base:extend()

function Previewer.cmd_async:new(o, opts)
  Previewer.cmd_async.super.new(self, o, opts)
  return self
end

local grep_tag = function(file, tag)
  local line = 1
  if not tag or not file then return line end
  local pattern = utils.ctag_escape(tag)
  -- ctags uses '$' at the end of short patterns
  -- 'rg|grep' does not match these properly when
  -- 'fileformat' isn't set to 'unix', when set to
  -- 'dos' we need to prepend '$' with '\r$' with 'rg'
  -- equivalent pattern to `rg --crlf`
  -- see discussion in #219
  pattern = pattern:gsub("%$$", "\\r??%$")
  local grep_cmd = vim.fn.executable("rg") == 1
      and { "rg", "--line-number" }
      or { "grep", "-n", "-P" }
  local cmd = utils.tbl_deep_clone(grep_cmd)
  table.insert(cmd, pattern)
  table.insert(cmd, file)
  local out, rc = utils.io_system(cmd)
  if rc == 0 then
    line = utils.tointeger(out:match("[^:]+")) or 1
  else
    utils.warn("Unable to match pattern '%s' in file '%s'", pattern, file)
  end
  return line
end

function Previewer.cmd_async:parse_entry_and_verify(entrystr)
  -- Handle empty or invalid entries (common on Windows with live_grep)
  if not entrystr or #entrystr == 0 or entrystr == "." or entrystr == ".." then
    local errmsg = entrystr and #entrystr > 0
        and string.format("'%s': Invalid entry", entrystr)
        or "No entry selected"
    return "", {}, "echo " .. libuv.shellescape(errmsg)
  end

  local entry = path.entry_to_file(entrystr, self.opts)
  if not entry.stripped then return end
  -- make relative for bat's header display
  local filepath = path.relative_to(entry.bufname or entry.path or "", utils.cwd())
  if self.opts._ctag then
    if not entry.line or tonumber(entry.line) < 1 then
      -- default tags are without line numbers
      entry.line = grep_tag(filepath, entry.ctag)
    end
  end
  local errcmd = nil
  if entry.debug then
    errcmd = "echo " .. libuv.shellescape(entry.debug)
  else
    -- verify the file exists on disk and is accessible
    if #filepath == 0 or not uv.fs_stat(filepath) then
      errcmd = "echo " .. libuv.shellescape(
        string.format("'%s: NO SUCH FILE OR ACCESS DENIED",
          filepath and #filepath > 0 and filepath or "<null>"))
    end
  end
  return filepath, entry, errcmd
end

function Previewer.cmd_async:cmdline(o)
  o = o or {}
  local act = shell.stringify_cmd(function(items)
    FzfLua.get_info().query = items[2] or ""
    local filepath, _, errcmd = self:parse_entry_and_verify(items[1])
    if not filepath then return utils.shell_nop() end
    local cmd = errcmd or ("%s %s %s"):format(
      self.cmd, self.args, libuv.shellescape(filepath))
    return cmd
  end, self.opts, "{} {q}")
  return act
end

---@class fzf-lua.previewer.BatAsync : fzf-lua.previewer.CmdAsync,{}
---@field super fzf-lua.previewer.CmdAsync,{}
Previewer.bat_async = Previewer.cmd_async:extend()

---@param lnum string?
---@return string?
function Previewer.bat_async:_preview_offset(lnum)
  --[[
    #
    #   Explanation of the fzf preview offset options:
    #
    #   ~3    Top 3 lines as the fixed header
    #   +{2}  Base scroll offset extracted from the second field
    #   +3    Extra offset to compensate for the 3-line header
    #   /2    Put in the middle of the preview area
    #
    '--preview-window '~3:+{2}+3/2''
  ]]
  lnum = lnum or self.opts.line_field_index
  if not self.args or not self.args:match("%-%-style=default") then
    -- we don't need affixed header unless we use bat default style
    -- TODO: should also adjust for "--style=header-filename"
    if lnum then
      return ("+%s-/2"):format(lnum)
    end
  else
    if lnum then
      return ("~3:+%s+3/2"):format(lnum)
    else
      -- no line offset, affix header
      return "~3"
    end
  end
end

function Previewer.bat_async:new(o, opts)
  Previewer.bat_async.super.new(self, o, opts)
  self.theme = o.theme
  return self
end

function Previewer.bat_async:cmdline(o)
  o = o or {}
  local act = function(items, fzf_lines)
    FzfLua.get_info().query = items[2] or ""
    if items[1] == "" then return utils.shell_nop() end
    local filepath, entry, errcmd = self:parse_entry_and_verify(items[1])
    if not filepath or not entry then return utils.shell_nop() end
    local line_range = ""
    if self.opts._ctag and entry.line then
      -- this is a ctag without line numbers, since we can't
      -- provide the preview file offset to fzf via the field
      -- index expression we use '--line-range' instead
      local start_line = math.floor((math.max(1, entry.line - fzf_lines / 2)))
      local end_line = start_line + fzf_lines - 1
      line_range = ("--line-range=%d:%d"):format(start_line, end_line)
    end
    local cmd = errcmd or ("%s %s %s %s %s %s"):format(
      self.cmd, self.args,
      self.theme and string.format([[--theme="%s"]],
        type(self.theme) == "function" and tostring(self.theme()) or tostring(self.theme)) or "",
      entry.line and entry.line > 0
      and string.format("--highlight-line=%d", entry.line) or "",
      line_range,
      libuv.shellescape(filepath))
    return cmd
  end
  return { fn = act, type = "cmd", field_index = "{} {q}" }
end

---@class fzf-lua.previewer.GitDiff : fzf-lua.previewer.Fzf,{}
---@field super fzf-lua.previewer.Fzf,{}
Previewer.git_diff = Previewer.base:extend()

---@param o fzf-lua.config.GitDiffPreviewer
---@param opts fzf-lua.config.Resolved
---@return fzf-lua.previewer.GitDiff
function Previewer.git_diff:new(o, opts)
  Previewer.git_diff.super.new(self, o, opts)
  self.cmd_deleted = path.git_cwd(o.cmd_deleted, opts)
  self.cmd_modified = path.git_cwd(o.cmd_modified, opts)
  self.cmd_untracked = path.git_cwd(o.cmd_untracked, opts)
  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
    -- style 2: as we are unable to use %var% within a "cmd /c" without !var! expansion
    -- https://superuser.com/questions/223104/setting-and-using-variable-within-same-command-line-in-windows-cmd-ex
    self.pager = "| " .. utils._if_win_normalize_vars(pager, 2)
  end
  do
    -- populate the icon mappings
    ---@diagnostic disable-next-line: assign-type-mismatch
    local icons_overrides = o._fn_git_icons and o._fn_git_icons()
    self.git_icons = {}
    for _, i in ipairs({ "D", "M", "R", "A", "C", "T", "?" }) do
      self.git_icons[i] =
          icons_overrides and icons_overrides[i] and
          utils.lua_regex_escape(icons_overrides[i].icon) or i
    end
  end
  return self
end

function Previewer.git_diff:cmdline(o)
  o = o or {}
  local act = function(items, fzf_lines, fzf_columns)
    if not items or utils.tbl_isempty(items) then
      utils.warn("shell error while running preview action.")
      return utils.shell_nop()
    end
    if not items[1] then return utils.shell_nop() end
    local is_deleted = items[1]:match(self.git_icons["D"] .. utils.nbsp) ~= nil
    local is_modified = items[1]:match("[" ..
      self.git_icons["M"] ..
      self.git_icons["R"] ..
      self.git_icons["A"] ..
      self.git_icons["T"] ..
      "]" .. utils.nbsp) ~= nil
    local is_untracked = items[1]:match("[" ..
      self.git_icons["?"] ..
      self.git_icons["C"] ..
      "]" .. utils.nbsp) ~= nil
    local s = items[1]
    if s:match("%s%->%s") then
      -- for renames, we take only the last part (#864)
      s = s:match("%s%->%s(.*)$") or s
    end
    local entry = path.entry_to_file(s, self.opts)
    local cmd = nil
    if is_modified then
      cmd = self.cmd_modified
    elseif is_deleted then
      cmd = self.cmd_deleted
    elseif is_untracked then
      local stat = entry.path and uv.fs_stat(entry.path)
      ---@diagnostic disable-next-line: undefined-field
      if stat and stat.type == "directory" then
        cmd = utils._if_win({ "dir" }, { "ls", "-la" })
      else
        cmd = self.cmd_untracked
      end
    end
    if not cmd then return "" end
    if type(cmd) == "table" then return table.concat(cmd, " ") end
    -- with default commands we add the filepath at the end.
    -- If the user configured a more complex command, e.g.:
    -- git_diff = {
    --   cmd_modified = "git diff --color HEAD %s | less -SEX"
    -- }
    -- we use ':format' directly on the user's command, see
    -- issue #392 for more info (limiting diff output width)
    if not entry.path then return "" end
    local fname_escaped = libuv.shellescape(entry.path)
    if cmd:match("[<{]file[}>]") then
      cmd = cmd:gsub("[<{]file[}>]", fname_escaped)
    elseif cmd:match("%%s") then
      cmd = cmd:format(fname_escaped)
    else
      cmd = string.format("%s %s", cmd, fname_escaped)
    end
    local env = {
      ["LINES"]               = fzf_lines,
      ["COLUMNS"]             = fzf_columns,
      ["FZF_PREVIEW_LINES"]   = fzf_lines,
      ["FZF_PREVIEW_COLUMNS"] = fzf_columns,
    }
    -- NOTE: no longer required to send env string as we're setting
    -- env vars in `stringify:libuv.spawn`
    -- local setenv = utils.shell_setenv_str(env)
    -- cmd = string.format("%s %s %s", table.concat(setenv, " "), cmd, pager)
    cmd = string.format("%s %s", cmd, self.pager or "")
    return { cmd = cmd, env = env }
  end
  return { fn = act, type = "cmd", field_index = "{} {q}" }
end

---@class fzf-lua.previewer.fzf.ManPages : fzf-lua.previewer.Fzf,{}
---@field super fzf-lua.previewer.Fzf,{}
Previewer.man_pages = Previewer.base:extend()

function Previewer.man_pages:new(o, opts)
  Previewer.man_pages.super.new(self, o, opts)
  self.cmd = o.cmd or "man -c %s | col -bx"
  ---@diagnostic disable-next-line: assign-type-mismatch
  self.cmd = type(self.cmd) == "function" and self.cmd() or self.cmd
  return self
end

function Previewer.man_pages:cmdline(o)
  o = o or {}
  local act = function(items)
    if not items[1] then return utils.shell_nop() end
    local manpage = require("fzf-lua.providers.manpages").manpage_sh_arg(items[1])
    local cmd = self.cmd:format(manpage)
    return cmd
  end
  return { fn = act, type = "cmd", field_index = "{}" }
end

---@class fzf-lua.previewer.fzf.HelpTags : fzf-lua.previewer.Fzf,{}
---@field super fzf-lua.previewer.fzf.HelpTags,{}
Previewer.help_tags = Previewer.base:extend()

function Previewer.help_tags:fzf_delimiter()
  return self.opts.fzf_opts and self.opts.fzf_opts["--delimiter"] or nil
end

function Previewer.help_tags:new(o, opts)
  Previewer.help_tags.super.new(self, o, opts)
  self.cmd = self.cmd or vim.fn.executable("bat") == 1
      and "bat -p -l help --color=always %s"
      or "cat %s"
  return self
end

function Previewer.help_tags:cmdline(o)
  o = o or {}
  local act = function(items)
    if not items[1] then return utils.shell_nop() end
    local vimdoc = items[1]:match(string.format("[^%s]+$", utils.nbsp))
    assert(vimdoc, "help_tags previewer: unable to parse help file name")
    local tag = items[1]:match("^[^%s]+")
    local ext = path.extension(vimdoc)
    local cmd = self.cmd:format(libuv.shellescape(vimdoc))
    -- If 'bat' is available attempt to get the helptag line
    -- and start the display of the help file from the tag
    if self.cmd:match("^bat ") then
      local line = grep_tag(vimdoc, ext == "md" and tag or string.format("*%s*", tag))
      if tonumber(line) > 0 then
        -- this is a ctag without line numbers, since we can't
        -- provide the preview file offset to fzf via the field
        -- index expression we use '--line-range' instead
        cmd = cmd .. string.format(" --line-range=%d:", tonumber(line))
      end
    end
    return cmd
  end
  return { fn = act, type = "cmd", field_index = "{}" }
end

Previewer.nvim_server = Previewer.cmd_async:extend()

-- Block (bounded) until a screenshot dump stops growing: the dump
-- streams in after the screenshot rpc acks, so a mere size>0 can catch a
-- partial file. Requires two consecutive stable non-empty sizes; `vim.wait`
-- pumps the event loop so deferred writes run.
---@param screenshot string
---@param timeout_ms integer? max wait, defaults to 2000
---@return boolean settled
local function wait_screenshot_settled(screenshot, timeout_ms)
  local last_size, stable = -1, 0
  local settled = vim.wait(timeout_ms or 2000, function()
    local size = vim.fn.getfsize(screenshot)
    if size > 0 and size == last_size then
      stable = stable + 1
      return stable >= 2
    end
    last_size, stable = size, 0
    return false
  end, 25)
  return settled
end

-- generate screenshot
-- spawn a temporary tui for non-tui/headless client
---@param screenshot string
---@param addr string
---@param lines integer
---@param columns integer
---@return boolean|integer? pid
---@return string|any, ...any error
local function make_screenshot(screenshot, addr, lines, columns)
  local closing = false
  -- timed: a wedged target must fail here, not freeze the picker forever
  local ok, uis = utils.rpcexec_timed(addr, "nvim_list_uis") --[[@as boolean, { stdout_tty: boolean }[]?]]
  vim.fn.writefile({}, screenshot)
  if not ok then -- instance seems died, signal to reload the list
    local winobj = FzfLua.utils.fzf_winobj()
    if winobj then winobj:SIGWINCH({ "win.unhide" }) end
    -- propagate the raw rpc error so the preview shows WHY (uis holds it)
    ---@diagnostic disable-next-line: missing-return-value
    return nil, uis
  end
  local uis_list = uis or {} ---@type { stdout_tty: boolean }[]
  local has_tui = false
  for _, info in ipairs(uis_list) do
    if info.stdout_tty then has_tui = true break end
  end
  if has_tui then
    -- deliberately untimed below: the target just proved responsive via
    -- the timed gatekeeper above, and a slow dump-ack must still complete
    -- (a timeout here would abort an otherwise healthy screenshot)
    if vim.env.NVIM == addr then -- parent instance
      return utils.rpcexec(addr, "nvim__screenshot", screenshot)
    end
    if utils.__IS_WINDOWS then
      -- Screenshot at native size: `vim._with({ o = { lines, columns }})`
      -- resizes the TARGET's UI (and every grid on it) which wedges
      -- remote/tmux terminals; fzf crops/scrolls oversized dumps itself.
      return utils.rpcexec(addr, "nvim_exec_lua",
        "return vim.api.nvim__screenshot(...)", { screenshot })
    end
    return utils.rpcexec(addr, "nvim_exec_lua", [[
      local lines, columns, screenshot = ...
      return vim._with({ o = { lines = lines, columns = columns } }, function()
        vim.api.nvim__screenshot(screenshot)
      end)
    ]], { lines, columns, screenshot })
  end
  local nvim_bin = os.getenv("FZF_LUA_NVIM_BIN") or vim.v.progpath
  local chan = vim.fn.jobstart({ nvim_bin, "--server", addr, "--remote-ui" }, {
    -- No pty on Windows: the ConPTY client exits(1) and its escape
    -- sequences leak to our stdout; plain pipes attach and dump fine.
    pty = not utils.__IS_WINDOWS,
    height = lines,
    width = columns,
    env = {
      TERM = "xterm-256color",
      VIMRUNTIME = os.getenv("VIMRUNTIME"),
    },
    on_stdout = function(chan0)
      if closing then return end
      closing = true
      -- deliberately untimed: the timed gatekeeper above already proved
      -- the target responsive, and capping this would drop slow-but-healthy
      -- dumps (the settle waiter and pager fallback bound the total wait)
      vim.defer_fn(function() utils.rpcexec(addr, "nvim__screenshot", screenshot) end, 10)
      vim.defer_fn(function()
        if utils.__IS_WINDOWS then
          -- The dump lands async after the rpc returns; killing the
          -- client at a fixed delay aborts it (permanently empty file).
          -- Wait for the settled dump first, same condition the pager
          -- side uses below.
          wait_screenshot_settled(screenshot, 2000)
        end
        vim.fn.jobstop(chan0)
      end, 20)
    end,
  })
  -- `jobpid` throws when the spawn failed (bad binary, no pty, ...);
  -- surface it as a preview error instead of a silent action failure.
  local ok_job, pid_or_err = pcall(vim.fn.jobpid, chan)
  if not ok_job then return nil, pid_or_err end
  ---@diagnostic disable-next-line: missing-return-value
  return pid_or_err
end

-- Server screenshot preview. On Windows the dump file is converted to plain
-- `\n` rows by `utils.screenshot_to_plaintext` (the raw dump is a terminal
-- script that does not render in every preview terminal) and paged with
-- `cat`/`type`; the target's own window size is never touched. Elsewhere
-- the raw dump is streamed with `tail`/`cat` after a `sleep`/`waitpid`
-- settle delay.
function Previewer.nvim_server:cmdline(_)
  local parse_entry = utils.serverlist_parse_entry
  local act = function(items, lines, columns)
    FzfLua.get_info().query = items[2] or ""
    local addr = parse_entry(items[1])
    if not addr then return utils.shell_nop() end
    local screenshot = assert(self.opts._screenshot) ---@type string
    local pid, err = make_screenshot(screenshot, addr, lines, columns)
    if err then
      return "echo " .. FzfLua.libuv.shellescape(string.format(
        "make_screenshot failed with error: %s", tostring(err)))
    end
    -- NOTE: preview runs under `cmd.exe /c` on Windows where `;` is not a
    -- command separator (`&` is) and coreutils may be absent, so only emit
    -- a wait prefix when the tool is actually executable and prefer pagers
    -- accordingly (`cat`, then builtin `type`, `tail` only as last resort
    -- since the header it would skip is already gone).
    local is_async = tonumber(pid) ~= nil
    local sep = utils._if_win("&", ";")
    local wait = ""
    if not utils.__IS_WINDOWS then
      if is_async and vim.fn.executable("waitpid") == 1 then
        wait = ("waitpid %s%s "):format(pid, sep)
      elseif vim.fn.executable("sleep") == 1 then
        wait = ("sleep %s%s "):format(50 / 1000, sep)
      end
    else
      -- No `sleep` under cmd.exe: wait in-process so the pager never reads
      -- an empty file. Needed on BOTH paths: `nvim__screenshot` only acks
      -- the rpc while the dump itself lands async (also after a cold
      -- `--remote-ui` client spawn, ~1s), and the client case additionally
      -- defers the shot. Settle (not just non-empty) so conversion never
      -- reads a partial dump. Generous cap for cold client spawns on
      -- loaded machines (the teardown waiter below runs independently and
      -- the next preview refresh retries); dead targets return early
      -- through the error path above (no stall).
      wait_screenshot_settled(screenshot, 3000)
      -- Render-safe rows (see `utils.screenshot_to_plaintext`); best
      -- effort, a failed conversion still leaves the raw dump for the pager.
      pcall(utils.screenshot_to_plaintext, screenshot)
    end
    local shot_path = screenshot
    if utils.__IS_WINDOWS then
      -- nightly neovim returns '/'-separated `tempname()` values on Windows
      -- and cmd.exe's `type` builtin rejects them; the pager runs cmd syntax
      shot_path = shot_path:gsub("/", "\\")
    end
    local shot = libuv.shellescape(shot_path)
    local pager
    if not utils.__IS_WINDOWS and vim.fn.executable("tail") == 1 then
      pager = "tail -n+2 %s"
    elseif vim.fn.executable("cat") == 1 then
      pager = "cat %s"
    elseif utils.__IS_WINDOWS then
      pager = "type %s"
    else
      -- No `tail`/`cat`: `tail` would still dump (minus its assumed header
      -- line), better than nothing. Unreachable on Windows (`type` above).
      pager = "tail -n+2 %s"
    end
    if utils.__IS_WINDOWS and vim.fn.getfsize(screenshot) <= 0 then
      -- Visible failure beats a silently empty preview pane; show the
      -- target address byte-escaped so invisible junk (\r, ANSI, ...) can't
      -- hide (pipe names never contain such bytes).
      return "echo " .. libuv.shellescape("screenshot not ready: "
        .. utils.escape_invisible(addr) .. " -> " .. screenshot)
    end
    return wait .. pager:format(shot)
  end
  return { fn = act, type = "cmd", field_index = "{} {q}" }
end

return Previewer