snacks.nvim/lua/snacks/picker/preview.lua
Anthony Qiu 2cf864aaa1
feat(picker): add author field to git log (#2295)
## Description

Currently in the picker you cant filter git log pickers by author, the
only option to do that right now is to pass it into the opts when you
call it like lua Snacks.picker.git_log({ author="test" }) but most of
the time I would like to filter interactively and also use the field
filtering offered by snacks like file:lua$ and with this new change,
author:test.

## Related Issue(s)

<!--
  If this PR fixes any issues, please link to the issue here.
  - Fixes #<issue_number>
-->

## Screenshots
<img width="760" height="842" alt="Screenshot 2025-10-13 at 11 03 28 PM"
src="https://github.com/user-attachments/assets/e57278aa-0fcd-4513-981d-fe8cfe078c64"
/>
<!-- Add screenshots of the changes if applicable. -->

---------

Co-authored-by: Folke Lemaitre <folke.lemaitre@gmail.com>
2025-10-21 16:38:24 +02:00

439 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)
if not path then
ctx.preview:notify("Item has no `file`", "error")
return
end
local name = 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(path) do
t = t or Snacks.util.path_type(path .. "/" .. file)
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 icon, icon_hl = Snacks.util.icon(item.file, cat, {
fallback = ctx.picker.opts.icons.files,
})
local line = { { icon .. " ", icon_hl }, { item.file, 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) <%an>",
"--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, author = 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,
author = author,
}, 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