Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions lua/snacks/picker/core/preview.lua
Original file line number Diff line number Diff line change
Expand Up @@ -314,14 +314,19 @@ function M:ns()
end

-- show the item location
function M:loc()
---@param pos? snacks.picker.Pos buffer-local position
---@param end_pos? snacks.picker.Pos buffer-local end position
function M:loc(pos, end_pos)
vim.api.nvim_buf_clear_namespace(self.win.buf, ns_loc, 0, -1)
if not self.item then
return
end

local line_count = vim.api.nvim_buf_line_count(self.win.buf)
Snacks.picker.util.resolve_loc(self.item, self.win.buf)
if not pos then
Snacks.picker.util.resolve_loc(self.item, self.win.buf)
pos, end_pos = self.item.pos, self.item.end_pos
end

local function show(pos)
local center = true
Expand All @@ -344,34 +349,34 @@ function M:loc()
end)
end

if self.item.pos and self.item.pos[1] > 0 and self.item.pos[1] <= line_count then
show(self.item.pos)
if pos and pos[1] > 0 and pos[1] <= line_count then
show(pos)
if self.item.positions then
for _, extmark in ipairs(Snacks.picker.highlight.matches({}, self.item.positions)) do
local col, row = extmark.col, self.item.pos[1]
local col, row = extmark.col, pos[1]
extmark.col = nil
extmark.row = nil
extmark.field = nil
extmark.hl_group = "SnacksPickerSearch"
pcall(vim.api.nvim_buf_set_extmark, self.win.buf, ns_loc, row - 1, col, extmark)
end
elseif self.item.end_pos then
vim.api.nvim_buf_set_extmark(self.win.buf, ns_loc, self.item.pos[1] - 1, self.item.pos[2], {
end_row = self.item.end_pos[1] - 1,
end_col = self.item.end_pos[2],
elseif end_pos then
vim.api.nvim_buf_set_extmark(self.win.buf, ns_loc, pos[1] - 1, pos[2], {
end_row = end_pos[1] - 1,
end_col = end_pos[2],
hl_group = "SnacksPickerSearch",
})
elseif self.filter and vim.trim(self.filter.search) ~= "" then
local ok, re = pcall(vim.regex, vim.trim(self.filter.search))
if ok and re then
local start = self.item.pos[2]
local start = pos[2]
local from, to ---@type number?, number?
pcall(function()
from, to = re:match_line(self.win.buf, self.item.pos[1] - 1, start)
from, to = re:match_line(self.win.buf, pos[1] - 1, start)
end)
if from and to then
show({ self.item.pos[1], start + to }) -- make sure the to column is visible
vim.api.nvim_buf_set_extmark(self.win.buf, ns_loc, self.item.pos[1] - 1, start + from, {
show({ pos[1], start + to }) -- make sure the to column is visible
vim.api.nvim_buf_set_extmark(self.win.buf, ns_loc, pos[1] - 1, start + from, {
end_col = start + to,
hl_group = "SnacksPickerSearch",
})
Expand Down
113 changes: 110 additions & 3 deletions lua/snacks/picker/preview.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,61 @@ local M = {}
local uv = vim.uv or vim.loop
local ns = vim.api.nvim_create_namespace("snacks.picker.preview")

---@param path string
---@param first number
---@param last number
---@return string[]?, string?
local function read_slice(path, first, last)
local file, err = io.open(path, "r")
if not file then
return nil, err
end
local lines = {}
local lnum = 0
for line in file:lines() do
lnum = lnum + 1
if lnum >= first then
lines[#lines + 1] = line
end
if lnum >= last then
break
end
end
file:close()
return lines
end

---@param ctx snacks.picker.preview.ctx
---@param pos snacks.picker.Pos
---@param lsp_pos lsp.Position
---@return snacks.picker.Pos
local function byte_pos(ctx, pos, lsp_pos)
local line = vim.api.nvim_buf_get_lines(ctx.buf, pos[1] - 1, pos[1], false)[1]
local col = line and Snacks.picker.util.str_byteindex(line, lsp_pos.character, ctx.item.loc.encoding) or pos[2]
return { pos[1], col }
end

---@param ctx snacks.picker.preview.ctx
---@param first number
---@return snacks.picker.Pos, snacks.picker.Pos?
local function slice_loc(ctx, first)
local item = ctx.item
local pos = { item.pos[1] - first + 1, item.pos[2] }
local last = first + vim.api.nvim_buf_line_count(ctx.buf) - 1
local end_pos = item.end_pos
and item.end_pos[1] >= first
and item.end_pos[1] <= last
and { item.end_pos[1] - first + 1, item.end_pos[2] }
or nil
if item.loc and not item.loc.resolved then
pos = byte_pos(ctx, pos, item.loc.range.start)
if end_pos then
end_pos = byte_pos(ctx, end_pos, item.loc.range["end"])
end
end
return pos, end_pos
end

---@param ctx snacks.picker.preview.ctx
function M.directory(ctx)
ctx.preview:reset()
Expand Down Expand Up @@ -78,6 +133,8 @@ end

---@param ctx snacks.picker.preview.ctx
function M.file(ctx)
-- sliced previews must not use the normal same-path reuse path
local sliced = ctx.preview.state.file_slice
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
Expand All @@ -102,6 +159,10 @@ function M.file(ctx)
end

if ctx.item.buf and vim.api.nvim_buf_is_loaded(ctx.item.buf) then
if sliced then
ctx.preview:reset()
ctx.preview.state.file_slice = nil
end
if not title then
local name = vim.api.nvim_buf_get_name(ctx.item.buf)
title = uv.fs_stat(name) and vim.fn.fnamemodify(name, ":t") or name
Expand All @@ -116,12 +177,19 @@ function M.file(ctx)
end

if Snacks.image.supports_file(path) and Snacks.image.config.enabled ~= false then
if sliced then
ctx.preview:reset()
ctx.preview.state.file_slice = nil
end
return M.image(ctx)
end

-- re-use existing preview when path is the same
if path ~= Snacks.picker.util.path(ctx.prev) then
local target_line = ctx.item.pos and ctx.item.pos[1]
local reuse = not sliced and path == Snacks.picker.util.path(ctx.prev)
if not reuse then
ctx.preview:reset()
ctx.preview.state.file_slice = nil
vim.bo[ctx.buf].buftype = ""

title = title or vim.fn.fnamemodify(path, ":t")
Expand All @@ -137,8 +205,47 @@ function M.file(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
if not target_line then
ctx.preview:notify("large file > 1MB", "warn")
return false
end
local count = math.max(10, vim.api.nvim_win_get_height(ctx.win) * 3)
local first = math.max(1, target_line - math.floor(count / 2))
local lines, err = read_slice(path, first, first + count - 1)
if not lines then
ctx.preview:notify(err or "failed to read file", "error")
return false
elseif #lines == 0 then
ctx.preview:notify("target line not found", "warn")
return false
end
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
for i, text in ipairs(lines) do
if #text > ctx.picker.opts.previewers.file.max_line_length then
text = text:sub(1, ctx.picker.opts.previewers.file.max_line_length) .. "..."
lines[i] = text
end
if text: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
end
if is_binary then
ctx.preview:wo({ number = false, relativenumber = false, cursorline = false, signcolumn = "no" })
end
ctx.preview:set_lines(lines)
ctx.preview.state.file_slice = true
ctx.preview:wo({ statuscolumn = "%=%{v:virtnum==0?v:lnum+" .. (first - 1) .. ":''} " })
ctx.preview:highlight({ file = path, ft = ctx.picker.opts.previewers.file.ft, buf = ctx.buf })
ctx.preview:loc(slice_loc(ctx, first))
return
end
if stat.size == 0 then
ctx.preview:notify("empty file", "warn")
Expand Down
148 changes: 148 additions & 0 deletions tests/picker/preview_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---@module 'luassert'

describe("picker file preview", function()
local Preview = require("snacks.picker.core.preview")
local preview, path, opts
local target_line = 4000
local other_target_line = 5000
local target_text = "😀 target"
local file_lines = 6000
local line_length = 210
local preview_height = 10
local slice_size = preview_height * 3
local context = math.floor(slice_size / 2)
local target_buf_line = context + 1

local function source_line(i)
if i == target_line then
return target_text
end
return ("line %05d %s"):format(i, ("x"):rep(line_length + (i % 7)))
end

local function write_large_file()
local lines = {}
for i = 1, file_lines do
lines[i] = source_line(i)
end
vim.fn.writefile(lines, path)
end

---@param pos? snacks.picker.Pos
---@param loc? snacks.picker.lsp.Loc
local function show(pos, loc)
local prev = preview.item
preview.item = {
file = path,
pos = pos,
end_pos = loc and { pos[1], loc.range["end"].character } or nil,
loc = loc,
}
require("snacks.picker.preview").file(setmetatable({
picker = { opts = opts },
preview = preview,
item = preview.item,
prev = prev,
}, {
__index = function(_, key)
return key == "buf" and preview.win.buf or key == "win" and preview.win.win
end,
}))
end

local function preview_lines()
return vim.api.nvim_buf_get_lines(preview.win.buf, 0, -1, false)
end

before_each(function()
path = vim.fn.tempname() .. ".txt"
write_large_file()
opts = { previewers = { file = { max_size = 1024 * 1024, max_line_length = 500 } } }
preview = setmetatable({
win = Snacks.win({ width = 60, height = preview_height, show = true, wo = { number = true } }),
state = {},
}, Preview)
end)

after_each(function()
preview.win:destroy()
vim.fn.delete(path)
end)

it("previews a bounded range around an oversized target", function()
assert.is_true(vim.uv.fs_stat(path).size > opts.previewers.file.max_size)
show({ target_line, 0 })

local displayed = preview_lines()
assert.equals(slice_size, #displayed)
assert.equals(source_line(target_line - context), displayed[1])
assert.equals(source_line(target_line), displayed[target_buf_line])
assert.same({ target_buf_line, 0 }, vim.api.nvim_win_get_cursor(preview.win.win))

local number = vim.api.nvim_eval_statusline(vim.wo[preview.win.win].statuscolumn, {
winid = preview.win.win,
use_statuscol_lnum = target_buf_line,
}).str
assert.equals(tostring(target_line), vim.trim(number))
end)

it("keeps the warning and clears stale contents without a target", function()
show({ target_line, 0 })
show()

assert.equals("warn: large file > 1MB", preview_lines()[1])
assert.is_nil(table.concat(preview_lines(), "\n"):find(target_text, 1, true))
assert.equals("", vim.wo[preview.win.win].statuscolumn)
end)

it("reloads a slice for another target in the same file", function()
show({ target_line, 0 })
show({ other_target_line, 0 })

assert.equals(source_line(other_target_line - context), preview_lines()[1])
assert.equals(source_line(other_target_line), preview_lines()[target_buf_line])
assert.same({ target_buf_line, 0 }, vim.api.nvim_win_get_cursor(preview.win.win))

local number = vim.api.nvim_eval_statusline(vim.wo[preview.win.win].statuscolumn, {
winid = preview.win.win,
use_statuscol_lnum = target_buf_line,
}).str
assert.equals(tostring(other_target_line), vim.trim(number))
end)

it("resolves an LSP column against the retained source line", function()
local loc = {
encoding = "utf-16",
range = {
start = { line = target_line - 1, character = 3 },
["end"] = { line = target_line - 1, character = 9 },
},
}
show({ target_line, 3 }, loc)

assert.same({ target_buf_line, 5 }, vim.api.nvim_win_get_cursor(preview.win.win))
assert.is_nil(loc.resolved)
end)

it("keeps the full-file path for files within max_size", function()
vim.fn.writefile({ "one", "two", "three" }, path)
show({ 2, 0 })

assert.same({ "one", "two", "three" }, preview_lines())
assert.same({ 2, 0 }, vim.api.nvim_win_get_cursor(preview.win.win))

local tick = vim.api.nvim_buf_get_changedtick(preview.win.buf)
show({ 3, 0 })
assert.equals(tick, vim.api.nvim_buf_get_changedtick(preview.win.buf))
assert.same({ 3, 0 }, vim.api.nvim_win_get_cursor(preview.win.win))
end)

it("clears stale contents when the file is missing", function()
preview:set_lines({ "stale" })
vim.fn.delete(path)
show({ 1, 0 })

assert.equals("error: file not found: " .. path, preview_lines()[1])
assert.is_nil(table.concat(preview_lines(), "\n"):find("stale", 1, true))
end)
end)