mirror of
https://github.com/folke/snacks.nvim
synced 2025-07-14 08:35:10 +00:00
434 lines
12 KiB
Lua
434 lines
12 KiB
Lua
---@class snacks.picker.previewers
|
|
local M = {}
|
|
|
|
local uv = vim.uv or vim.loop
|
|
local ns = vim.api.nvim_create_namespace("snacks.picker.preview")
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.directory(ctx)
|
|
ctx.preview:reset()
|
|
ctx.preview:minimal()
|
|
local path = Snacks.picker.util.path(ctx.item)
|
|
local name = path and vim.fn.fnamemodify(path, ":t")
|
|
ctx.preview:set_title(ctx.item.title or name)
|
|
local ls = {} ---@type {file:string, type:"file"|"directory"}[]
|
|
for file, t in vim.fs.dir(ctx.item.file) do
|
|
ls[#ls + 1] = { file = file, type = t }
|
|
end
|
|
ctx.preview:set_lines(vim.split(string.rep("\n", #ls), "\n"))
|
|
table.sort(ls, function(a, b)
|
|
if a.type ~= b.type then
|
|
return a.type == "directory"
|
|
end
|
|
return a.file < b.file
|
|
end)
|
|
for i, item in ipairs(ls) do
|
|
local is_dir = item.type == "directory"
|
|
local cat = is_dir and "directory" or "file"
|
|
local hl = is_dir and "Directory" or nil
|
|
local path = item.file
|
|
local icon, icon_hl = Snacks.util.icon(path, cat, {
|
|
fallback = ctx.picker.opts.icons.files,
|
|
})
|
|
local line = { { icon .. " ", icon_hl }, { path, hl } }
|
|
vim.api.nvim_buf_set_extmark(ctx.buf, ns, i - 1, 0, {
|
|
virt_text = line,
|
|
})
|
|
end
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.image(ctx)
|
|
local buf = ctx.preview:scratch()
|
|
ctx.preview:set_title(ctx.item.title or vim.fn.fnamemodify(ctx.item.file, ":t"))
|
|
Snacks.image.buf.attach(buf, { src = Snacks.picker.util.path(ctx.item) })
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.none(ctx)
|
|
ctx.preview:reset()
|
|
ctx.preview:notify("no preview available", "warn")
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.preview(ctx)
|
|
if ctx.item.preview == "file" then
|
|
return M.file(ctx)
|
|
end
|
|
assert(type(ctx.item.preview) == "table", "item.preview must be a table")
|
|
ctx.preview:reset()
|
|
local lines = vim.split(ctx.item.preview.text, "\n")
|
|
ctx.preview:set_lines(lines)
|
|
if ctx.item.preview.ft then
|
|
ctx.preview:highlight({ ft = ctx.item.preview.ft })
|
|
end
|
|
for _, extmark in ipairs(ctx.item.preview.extmarks or {}) do
|
|
local e = vim.deepcopy(extmark)
|
|
e.col, e.row = nil, nil
|
|
vim.api.nvim_buf_set_extmark(ctx.buf, ns, (extmark.row or 1) - 1, extmark.col, e)
|
|
end
|
|
if ctx.item.preview.loc ~= false then
|
|
ctx.preview:loc()
|
|
end
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.file(ctx)
|
|
if ctx.item.buf and not ctx.item.file and not vim.api.nvim_buf_is_valid(ctx.item.buf) then
|
|
ctx.preview:notify("Buffer no longer exists", "error")
|
|
return
|
|
end
|
|
|
|
-- used by some LSP servers that load buffers with custom URIs
|
|
if ctx.item.buf and vim.uri_from_bufnr(ctx.item.buf):sub(1, 4) ~= "file" then
|
|
vim.fn.bufload(ctx.item.buf)
|
|
elseif ctx.item.file and ctx.item.file:find("^%w+://") then
|
|
ctx.item.buf = vim.fn.bufadd(ctx.item.file)
|
|
vim.fn.bufload(ctx.item.buf)
|
|
end
|
|
|
|
if ctx.item.buf and vim.api.nvim_buf_is_loaded(ctx.item.buf) then
|
|
local name = vim.api.nvim_buf_get_name(ctx.item.buf)
|
|
name = uv.fs_stat(name) and vim.fn.fnamemodify(name, ":t") or name
|
|
ctx.preview:set_title(name)
|
|
ctx.preview:set_buf(ctx.item.buf)
|
|
else
|
|
local path = Snacks.picker.util.path(ctx.item)
|
|
if not path then
|
|
ctx.preview:notify("Item has no `file`", "error")
|
|
return
|
|
end
|
|
|
|
if Snacks.image.supports_file(path) then
|
|
return M.image(ctx)
|
|
end
|
|
|
|
-- re-use existing preview when path is the same
|
|
if path ~= Snacks.picker.util.path(ctx.prev) then
|
|
ctx.preview:reset()
|
|
vim.bo[ctx.buf].buftype = ""
|
|
|
|
local name = vim.fn.fnamemodify(path, ":t")
|
|
ctx.preview:set_title(ctx.item.title or name)
|
|
|
|
local stat = uv.fs_stat(path)
|
|
if not stat then
|
|
ctx.preview:notify("file not found: " .. path, "error")
|
|
return false
|
|
end
|
|
if stat.type == "directory" then
|
|
return M.directory(ctx)
|
|
end
|
|
local max_size = ctx.picker.opts.previewers.file.max_size or (1024 * 1024)
|
|
if stat.size > max_size then
|
|
ctx.preview:notify("large file > 1MB", "warn")
|
|
return false
|
|
end
|
|
if stat.size == 0 then
|
|
ctx.preview:notify("empty file", "warn")
|
|
return false
|
|
end
|
|
|
|
local file = assert(io.open(path, "r"))
|
|
|
|
local is_binary = false
|
|
local ft = ctx.picker.opts.previewers.file.ft or vim.filetype.match({ filename = path })
|
|
if ft == "bigfile" then
|
|
ft = nil
|
|
end
|
|
local lines = {}
|
|
for line in file:lines() do
|
|
---@cast line string
|
|
if #line > ctx.picker.opts.previewers.file.max_line_length then
|
|
line = line:sub(1, ctx.picker.opts.previewers.file.max_line_length) .. "..."
|
|
end
|
|
-- Check for binary data in the current line
|
|
if line:find("[%z\1-\8\11\12\14-\31]") then
|
|
is_binary = true
|
|
if not ft then
|
|
ctx.preview:notify("binary file", "warn")
|
|
return
|
|
end
|
|
end
|
|
table.insert(lines, line)
|
|
end
|
|
|
|
file:close()
|
|
|
|
if is_binary then
|
|
ctx.preview:wo({ number = false, relativenumber = false, cursorline = false, signcolumn = "no" })
|
|
end
|
|
ctx.preview:set_lines(lines)
|
|
ctx.preview:highlight({ file = path, ft = ctx.picker.opts.previewers.file.ft, buf = ctx.buf })
|
|
end
|
|
end
|
|
ctx.preview:loc()
|
|
end
|
|
|
|
---@param cmd string[]
|
|
---@param ctx snacks.picker.preview.ctx
|
|
---@param opts? {add?:fun(text:string, row:number), env?:table<string, string>, pty?:boolean, ft?:string, input?:string}
|
|
function M.cmd(cmd, ctx, opts)
|
|
opts = opts or {}
|
|
local buf = ctx.preview:scratch()
|
|
vim.bo[buf].buftype = "nofile"
|
|
local pty = opts.pty ~= false and not opts.ft
|
|
local killed = false
|
|
local chan = pty and vim.api.nvim_open_term(buf, {}) or nil
|
|
local output = {} ---@type string[]
|
|
local line ---@type string?
|
|
local l = 0
|
|
|
|
if ctx.picker.opts.debug.proc then
|
|
local args = vim.deepcopy(cmd)
|
|
table.remove(args, 1)
|
|
vim.schedule(function()
|
|
Snacks.debug.cmd({ cmd = cmd[1], args = args, cwd = ctx.item.cwd, group = true })
|
|
end)
|
|
end
|
|
|
|
---@param text string
|
|
local function add_line(text)
|
|
l = l + 1
|
|
vim.bo[buf].modifiable = true
|
|
if opts.add then
|
|
opts.add(text, l)
|
|
else
|
|
vim.api.nvim_buf_set_lines(buf, l - 1, l, false, { text })
|
|
end
|
|
vim.bo[buf].modifiable = false
|
|
end
|
|
|
|
---@param data string
|
|
local function add(data)
|
|
output[#output + 1] = data
|
|
if chan then
|
|
if pcall(vim.api.nvim_chan_send, chan, data) then
|
|
vim.api.nvim_buf_call(buf, function()
|
|
vim.cmd("norm! gg")
|
|
end)
|
|
end
|
|
else
|
|
line = (line or "") .. data
|
|
local lines = vim.split(line, "\r?\n")
|
|
line = table.remove(lines)
|
|
for _, text in ipairs(lines) do
|
|
add_line(text)
|
|
end
|
|
end
|
|
end
|
|
|
|
local jid = vim.fn.jobstart(cmd, {
|
|
height = pty and vim.api.nvim_win_get_height(ctx.win) or nil,
|
|
width = pty and vim.api.nvim_win_get_width(ctx.win) or nil,
|
|
-- a bit weird, but we need to set `pty` to `nil` when `opts.input` is set
|
|
-- otherwise the job never receives the input.
|
|
-- Probably won't work with all commands
|
|
pty = not opts.input and pty or nil,
|
|
cwd = ctx.item.cwd or ctx.picker.opts.cwd,
|
|
env = vim.tbl_extend("force", {
|
|
PAGER = "cat",
|
|
DELTA_PAGER = "cat",
|
|
}, opts.env or {}),
|
|
on_stdout = function(_, data)
|
|
if not vim.api.nvim_buf_is_valid(buf) then
|
|
return
|
|
end
|
|
add(table.concat(data, "\n"))
|
|
end,
|
|
on_exit = function(_, code)
|
|
if not killed and line and line ~= "" and vim.api.nvim_buf_is_valid(buf) then
|
|
add_line(line)
|
|
end
|
|
if not killed and code ~= 0 then
|
|
Snacks.notify.error(
|
|
("Terminal **cmd** `%s` failed with code `%d`:\n- `vim.o.shell = %q`\n\nOutput:\n%s"):format(
|
|
type(cmd) == "table" and table.concat(cmd, " ") or cmd,
|
|
code,
|
|
vim.o.shell,
|
|
vim.trim(table.concat(output, ""))
|
|
)
|
|
)
|
|
end
|
|
end,
|
|
sync = true,
|
|
})
|
|
if jid <= 0 then
|
|
Snacks.notify.error(("Failed to start terminal **cmd** `%s`"):format(cmd))
|
|
if chan then
|
|
vim.fn.chanclose(chan)
|
|
end
|
|
return
|
|
end
|
|
|
|
if opts.input then
|
|
vim.fn.chansend(jid, opts.input .. "\n")
|
|
vim.fn.chanclose(jid, "stdin")
|
|
end
|
|
if opts.ft then
|
|
ctx.preview:highlight({ ft = opts.ft })
|
|
end
|
|
vim.api.nvim_create_autocmd("BufWipeout", {
|
|
buffer = buf,
|
|
callback = function()
|
|
killed = true
|
|
vim.fn.jobstop(jid)
|
|
if chan then
|
|
vim.fn.chanclose(chan)
|
|
end
|
|
end,
|
|
})
|
|
return jid
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.git_show(ctx)
|
|
local builtin = ctx.picker.opts.previewers.git.builtin
|
|
local cmd = {
|
|
"git",
|
|
"-c",
|
|
"delta." .. vim.o.background .. "=true",
|
|
"show",
|
|
ctx.item.commit,
|
|
}
|
|
local pathspec = ctx.item.files or ctx.item.file
|
|
pathspec = type(pathspec) == "table" and pathspec or { pathspec }
|
|
if #pathspec > 0 then
|
|
cmd[#cmd + 1] = "--"
|
|
vim.list_extend(cmd, pathspec)
|
|
end
|
|
if builtin then
|
|
table.insert(cmd, 2, "--no-pager")
|
|
end
|
|
M.cmd(cmd, ctx, { ft = builtin and "git" or nil })
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
local function git(ctx, ...)
|
|
local ret = { "git", "-c", "delta." .. vim.o.background .. "=true" }
|
|
vim.list_extend(ret, ctx.picker.opts.previewers.git.args or {})
|
|
vim.list_extend(ret, { ... })
|
|
return ret
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.git_log(ctx)
|
|
local cmd = git(
|
|
ctx,
|
|
"--no-pager",
|
|
"log",
|
|
"--pretty=format:%h %s (%ch)",
|
|
"--abbrev-commit",
|
|
"--decorate",
|
|
"--date=short",
|
|
"--color=never",
|
|
"--no-show-signature",
|
|
"--no-patch",
|
|
ctx.item.commit
|
|
)
|
|
local row = 0
|
|
M.cmd(cmd, ctx, {
|
|
ft = "git",
|
|
---@param text string
|
|
add = function(text)
|
|
local commit, msg, date = text:match("^(%S+) (.*) %((.*)%)$")
|
|
if commit then
|
|
row = row + 1
|
|
local hl = Snacks.picker.format.git_log({
|
|
idx = 1,
|
|
score = 0,
|
|
text = "",
|
|
commit = commit,
|
|
msg = msg,
|
|
date = date,
|
|
}, ctx.picker)
|
|
Snacks.picker.highlight.set(ctx.buf, ns, row, hl)
|
|
end
|
|
end,
|
|
})
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.diff(ctx)
|
|
local builtin = ctx.picker.opts.previewers.diff.builtin
|
|
if builtin then
|
|
ctx.item.preview = { text = ctx.item.diff, ft = "diff" }
|
|
return M.preview(ctx)
|
|
end
|
|
local cmd = vim.deepcopy(ctx.picker.opts.previewers.diff.cmd)
|
|
if cmd[1] == "delta" then
|
|
table.insert(cmd, 2, "--" .. vim.o.background)
|
|
end
|
|
M.cmd(cmd, ctx, {
|
|
pty = true,
|
|
input = ctx.item.diff,
|
|
})
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.git_diff(ctx)
|
|
local builtin = ctx.picker.opts.previewers.git.builtin
|
|
local cmd = git(ctx, "diff", "HEAD")
|
|
if ctx.item.file then
|
|
vim.list_extend(cmd, { "--", ctx.item.file })
|
|
end
|
|
if builtin then
|
|
table.insert(cmd, 2, "--no-pager")
|
|
end
|
|
M.cmd(cmd, ctx, { ft = builtin and "diff" or nil })
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.git_stash(ctx)
|
|
local builtin = ctx.picker.opts.previewers.git.builtin
|
|
local cmd = git(ctx, "stash", "show", "--patch", ctx.item.stash)
|
|
if builtin then
|
|
table.insert(cmd, 2, "--no-pager")
|
|
end
|
|
M.cmd(cmd, ctx, { ft = builtin and "diff" or nil })
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.git_status(ctx)
|
|
local ss = ctx.item.status
|
|
if ss:find("^[A?]") then
|
|
M.file(ctx)
|
|
else
|
|
M.git_diff(ctx)
|
|
end
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.colorscheme(ctx)
|
|
if not ctx.preview.state.colorscheme then
|
|
ctx.preview.state.colorscheme = vim.g.colors_name or "default"
|
|
ctx.preview.state.background = vim.o.background
|
|
ctx.preview.win:on("WinClosed", function()
|
|
vim.schedule(function()
|
|
if not ctx.preview.state.colorscheme then
|
|
return
|
|
end
|
|
vim.cmd("colorscheme " .. ctx.preview.state.colorscheme)
|
|
vim.o.background = ctx.preview.state.background
|
|
end)
|
|
end, { win = true })
|
|
end
|
|
vim.schedule(function()
|
|
vim.cmd("colorscheme " .. ctx.item.text)
|
|
end)
|
|
Snacks.picker.preview.file(ctx)
|
|
end
|
|
|
|
---@param ctx snacks.picker.preview.ctx
|
|
function M.man(ctx)
|
|
M.cmd({ "man", ctx.item.section, ctx.item.page }, ctx, {
|
|
ft = "man",
|
|
env = {
|
|
MANPAGER = ctx.picker.opts.previewers.man_pager or vim.fn.executable("col") == 1 and "col -bx" or "cat",
|
|
MANWIDTH = tostring(ctx.preview.win:dim().width),
|
|
MANPATH = vim.env.MANPATH,
|
|
},
|
|
})
|
|
end
|
|
|
|
return M
|