From 8694285bfd383dd5596cc1f771728cb351087018 Mon Sep 17 00:00:00 2001 From: Nick Lash Date: Sat, 5 Sep 2026 14:49:36 -0500 Subject: [PATCH] feat(picker.preview): preview oversized files around target --- lua/snacks/picker/core/preview.lua | 31 +++--- lua/snacks/picker/preview.lua | 113 +++++++++++++++++++++- tests/picker/preview_spec.lua | 148 +++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 tests/picker/preview_spec.lua diff --git a/lua/snacks/picker/core/preview.lua b/lua/snacks/picker/core/preview.lua index 59f672c21..473cbb34d 100644 --- a/lua/snacks/picker/core/preview.lua +++ b/lua/snacks/picker/core/preview.lua @@ -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 @@ -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", }) diff --git a/lua/snacks/picker/preview.lua b/lua/snacks/picker/preview.lua index 462b9aa03..293cfb4ce 100644 --- a/lua/snacks/picker/preview.lua +++ b/lua/snacks/picker/preview.lua @@ -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() @@ -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 @@ -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 @@ -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") @@ -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") diff --git a/tests/picker/preview_spec.lua b/tests/picker/preview_spec.lua new file mode 100644 index 000000000..a1c4f0f1b --- /dev/null +++ b/tests/picker/preview_spec.lua @@ -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)