mirror of
https://github.com/folke/snacks.nvim
synced 2025-12-23 08:47:57 +00:00
## 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>
439 lines
12 KiB
Lua
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
|