1b279d4d10
Lua CI / lua-language-server type check (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Python CI / Python bindings (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 3s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 1s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 3s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 2s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 1s
Rust CI / cargo fmt (push) Failing after 1s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 4s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 1s
Rust CI / cargo clippy (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 1s
Stylua / Check lua files using Stylua (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 4s
e2e Tests / e2e (alpine-musl) (push) Successful in 58m54s
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
e2e Tests / e2e (macos-latest) (push) Has been cancelled
e2e Tests / e2e (windows-latest) (push) Has been cancelled
Nix CI / check (push) Has been cancelled
Python CI / Python bindings (macos-latest) (push) Has been cancelled
Python CI / Python bindings (windows-latest) (push) Has been cancelled
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
Rust CI / Test (macos-latest) (push) Has been cancelled
Rust CI / Test (windows-latest) (push) Has been cancelled
Rust CI / Fuzz Tests (macos-latest) (push) Has been cancelled
Rust CI / Fuzz Tests (windows-latest) (push) Has been cancelled
947 lines
33 KiB
Lua
947 lines
33 KiB
Lua
local utils = require('fff.utils')
|
|
local file_picker = require('fff.file_picker')
|
|
local image = require('fff.file_picker.image')
|
|
local location_utils = require('fff.location_utils')
|
|
local rust = require('fff.rust')
|
|
local file_info_renderer = require('fff.file_picker.file_info')
|
|
|
|
local M = {}
|
|
|
|
-- Namespace dedicated to the file info panel highlights.
|
|
M.file_info_ns = vim.api.nvim_create_namespace('fff_file_info')
|
|
|
|
local function set_buffer_lines(bufnr, lines)
|
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
|
|
|
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
|
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
|
end
|
|
|
|
local function append_buffer_lines(bufnr, lines)
|
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
|
if not lines or #lines == 0 then return end
|
|
|
|
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
|
local current_lines = vim.api.nvim_buf_line_count(bufnr)
|
|
vim.api.nvim_buf_set_lines(bufnr, current_lines, current_lines, false, lines)
|
|
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
|
end
|
|
|
|
local function show_preview_unavailable(bufnr) set_buffer_lines(bufnr, { 'No preview available' }) end
|
|
|
|
local function set_preview_filetype(bufnr, filetype)
|
|
-- FileType hooks can throw or mutate global v:errmsg during preview setup.
|
|
-- pcall lets us restore it; callers treat failures as preview-local.
|
|
local errmsg = vim.v.errmsg
|
|
local ok, err = pcall(vim.api.nvim_set_option_value, 'filetype', filetype, { buf = bufnr })
|
|
vim.v.errmsg = errmsg
|
|
return ok, err
|
|
end
|
|
|
|
local function find_existing_buffer(file_path)
|
|
local abs_path = vim.fn.resolve(vim.fn.fnamemodify(file_path, ':p'))
|
|
|
|
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
|
|
if vim.api.nvim_buf_is_loaded(bufnr) then
|
|
local buf_name = vim.api.nvim_buf_get_name(bufnr)
|
|
if buf_name ~= '' then
|
|
local buf_path = vim.fn.resolve(vim.fn.fnamemodify(buf_name, ':p'))
|
|
if buf_path == abs_path then return bufnr end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function cleanup_file_operation()
|
|
if M.state.file_operation then
|
|
if M.state.file_operation.fd then pcall(vim.uv.fs_close, M.state.file_operation.fd) end
|
|
M.state.file_operation = nil
|
|
end
|
|
end
|
|
|
|
--- Process raw chunk data into complete lines, joining any leftover bytes
|
|
--- from the previous chunk and storing any trailing partial line for the next.
|
|
--- @param data string Raw chunk data
|
|
--- @return string[] Complete lines (may be empty if the entire chunk is a partial line)
|
|
local function split_chunk_with_remainder(data)
|
|
if not data or data == '' then return {} end
|
|
|
|
local fo = M.state.file_operation
|
|
local prefix = fo and fo.remainder or ''
|
|
local combined = prefix .. data
|
|
|
|
local lines = vim.split(combined, '\n', { plain = true })
|
|
|
|
if combined:sub(-1) ~= '\n' then
|
|
-- Data doesn't end on a line boundary: last element is a partial line
|
|
local partial = table.remove(lines) or ''
|
|
if fo then fo.remainder = partial end
|
|
else
|
|
-- Data ends on a line boundary: remove the trailing empty element
|
|
if #lines > 0 and lines[#lines] == '' then table.remove(lines) end
|
|
if fo then fo.remainder = '' end
|
|
end
|
|
|
|
return lines
|
|
end
|
|
|
|
local function init_dynamic_loading_async(file_path, callback)
|
|
cleanup_file_operation()
|
|
|
|
M.state.loaded_lines = 0
|
|
M.state.total_file_lines = nil
|
|
M.state.has_more_content = true
|
|
M.state.is_loading = false
|
|
|
|
local generation = M.state.preview_generation
|
|
|
|
vim.uv.fs_open(file_path, 'r', 438, function(err, fd)
|
|
vim.schedule(function()
|
|
-- Stale callback: preview moved on to a different file
|
|
if M.state.preview_generation ~= generation then
|
|
if fd then pcall(vim.uv.fs_close, fd) end
|
|
return
|
|
end
|
|
|
|
if err or not fd then
|
|
callback(false, 'Failed to open file: ' .. (err or 'unknown error'))
|
|
return
|
|
end
|
|
|
|
M.state.file_operation = {
|
|
fd = fd,
|
|
file_path = file_path,
|
|
position = 0,
|
|
remainder = '',
|
|
}
|
|
|
|
callback(true)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
local function load_forward_chunk_async(target_size, callback)
|
|
if not M.state.file_operation or not M.state.file_operation.fd then
|
|
callback('', 'No file handle available')
|
|
return
|
|
end
|
|
|
|
M.state.is_loading = true
|
|
local chunk_size = target_size or (M.config.chunk_size or 16384)
|
|
local generation = M.state.preview_generation
|
|
|
|
vim.uv.fs_read(M.state.file_operation.fd, chunk_size, M.state.file_operation.position, function(err, data)
|
|
vim.schedule(function()
|
|
-- Stale callback: a newer preview has started, discard this result
|
|
if M.state.preview_generation ~= generation then return end
|
|
|
|
M.state.is_loading = false
|
|
|
|
if err then
|
|
callback('', 'Read error: ' .. err)
|
|
return
|
|
end
|
|
|
|
if not data or #data == 0 then
|
|
M.state.has_more_content = false
|
|
-- Flush any remaining partial line as the final piece of data
|
|
local final_remainder = M.state.file_operation and M.state.file_operation.remainder or ''
|
|
cleanup_file_operation()
|
|
if final_remainder ~= '' then
|
|
callback(final_remainder .. '\n', nil)
|
|
else
|
|
callback('', nil)
|
|
end
|
|
return
|
|
end
|
|
|
|
if M.state.file_operation then M.state.file_operation.position = M.state.file_operation.position + #data end
|
|
|
|
callback(data, nil)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
local function load_next_chunk_async(chunk_size, callback)
|
|
if not M.state.file_operation or not M.state.has_more_content or M.state.is_loading then
|
|
callback('', nil)
|
|
return
|
|
end
|
|
load_forward_chunk_async(chunk_size, callback)
|
|
end
|
|
|
|
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
|
|
local ensure_content_loaded_async
|
|
|
|
local function read_file_streaming_async(file_path, callback)
|
|
local generation = M.state.preview_generation
|
|
|
|
init_dynamic_loading_async(file_path, function(success, error_msg)
|
|
if M.state.preview_generation ~= generation then return end
|
|
|
|
if not success then
|
|
callback(nil, error_msg)
|
|
return
|
|
end
|
|
|
|
-- Calculate initial chunk size based on location information
|
|
local initial_chunk_size = M.config.chunk_size
|
|
if M.state.location then
|
|
local target_line = location_utils.get_target_line(M.state.location)
|
|
if target_line then
|
|
-- Estimate bytes needed: assume ~100 bytes per line average
|
|
-- Add some buffer (50%) to account for variation in line lengths
|
|
local estimated_bytes = target_line * 100 * 1.5
|
|
-- Cap at reasonable maximum to avoid memory issues
|
|
local max_initial_chunk = M.config.max_size or (10 * 1024 * 1024) -- 10MB default
|
|
initial_chunk_size = math.min(estimated_bytes, max_initial_chunk)
|
|
-- Ensure we don't go below the standard chunk size
|
|
initial_chunk_size = math.max(initial_chunk_size, M.config.chunk_size)
|
|
end
|
|
end
|
|
|
|
load_next_chunk_async(initial_chunk_size, function(data, err)
|
|
if M.state.preview_generation ~= generation then return end
|
|
|
|
if data and data ~= '' then
|
|
local lines = split_chunk_with_remainder(data)
|
|
M.state.loaded_lines = #lines
|
|
M.state.content_height = #lines
|
|
|
|
-- If we have a location and didn't load enough lines, try to load more
|
|
local loading_more = false
|
|
if M.state.location then
|
|
local target_line = location_utils.get_target_line(M.state.location)
|
|
if target_line and #lines < target_line and M.state.has_more_content then
|
|
loading_more = true
|
|
vim.schedule(function()
|
|
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
|
|
end)
|
|
end
|
|
end
|
|
|
|
callback(lines, err, loading_more)
|
|
else
|
|
callback(nil, err)
|
|
end
|
|
end)
|
|
end)
|
|
end
|
|
|
|
ensure_content_loaded_async = function(target_line)
|
|
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
|
if not M.state.has_more_content or M.state.is_loading then return end
|
|
-- Guard against missing file handle: without it load_next_chunk_async returns
|
|
-- synchronously with empty data, which triggers apply_location_highlighting
|
|
-- -> ensure_content_loaded_async again, causing infinite recursion (stack overflow).
|
|
if not M.state.file_operation then
|
|
M.state.has_more_content = false
|
|
return
|
|
end
|
|
|
|
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
|
local buffer_needed = target_line + 50
|
|
|
|
if current_buffer_lines >= buffer_needed then return end
|
|
|
|
local generation = M.state.preview_generation
|
|
|
|
-- Use a larger chunk to reach the target faster instead of many small 8KB reads
|
|
local lines_needed = buffer_needed - current_buffer_lines
|
|
local estimated_bytes = math.max(M.config.chunk_size, lines_needed * 120)
|
|
|
|
load_next_chunk_async(estimated_bytes, function(data, err)
|
|
-- Stale callback: preview moved on to a different file
|
|
if M.state.preview_generation ~= generation then return end
|
|
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
|
|
|
if err then return end
|
|
|
|
if data and data ~= '' then
|
|
local chunk_lines = split_chunk_with_remainder(data)
|
|
if #chunk_lines > 0 then append_buffer_lines(M.state.bufnr, chunk_lines) end
|
|
|
|
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
|
|
M.state.loaded_lines = M.state.content_height
|
|
|
|
-- If we still haven't loaded enough, schedule another chunk
|
|
if M.state.loaded_lines < buffer_needed and M.state.has_more_content then
|
|
vim.schedule(function()
|
|
if M.state.preview_generation == generation then ensure_content_loaded_async(target_line) end
|
|
end)
|
|
else
|
|
-- Enough content loaded — re-apply location highlighting so the
|
|
-- preview scrolls to the correct line now that it exists in the buffer
|
|
M.apply_location_highlighting(M.state.bufnr)
|
|
end
|
|
else
|
|
-- EOF with no additional data — mark loading as finished to prevent
|
|
-- apply_location_highlighting -> ensure_content_loaded_async recursion,
|
|
-- then apply highlighting with whatever content we have.
|
|
M.state.has_more_content = false
|
|
M.apply_location_highlighting(M.state.bufnr)
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function link_buffer_content(source_bufnr, target_bufnr)
|
|
local lines = vim.api.nvim_buf_get_lines(source_bufnr, 0, -1, false)
|
|
set_buffer_lines(target_bufnr, lines)
|
|
|
|
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
|
|
if source_ft ~= '' then
|
|
local ok = set_preview_filetype(target_bufnr, source_ft)
|
|
if not ok then return false end
|
|
end
|
|
|
|
M.state.has_more_content = false
|
|
M.state.total_file_lines = #lines
|
|
M.state.loaded_lines = #lines
|
|
M.state.content_height = #lines
|
|
|
|
return true
|
|
end
|
|
|
|
M.config = nil
|
|
|
|
M.state = {
|
|
bufnr = nil,
|
|
winid = nil,
|
|
current_file = nil,
|
|
scroll_offset = 0,
|
|
content_height = 0,
|
|
loaded_lines = 0,
|
|
total_file_lines = nil,
|
|
loading_chunk_size = 1000,
|
|
is_loading = false,
|
|
has_more_content = true,
|
|
file_handle = nil, ---@type uv.uv_fs_t|nil
|
|
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
|
|
location = nil, -- Current location data for highlighting
|
|
location_namespace = nil, -- Namespace for location highlighting
|
|
preview_generation = 0, -- Monotonically increasing token to detect stale async callbacks
|
|
is_binary_preview = false, -- Whether the current preview is a hex dump
|
|
hex_byte_offset = 0, -- Next byte offset for hex dump paging
|
|
}
|
|
|
|
--- Setup preview configuration
|
|
--- @param config table Configuration options
|
|
function M.setup(config)
|
|
M.config = config or {}
|
|
-- Create namespace for location highlighting
|
|
if not M.state.location_namespace then
|
|
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
|
|
end
|
|
end
|
|
|
|
--- Check if file is too big for initial preview (inspired by snacks.nvim)
|
|
--- @param file_path string Path to the file
|
|
--- @return boolean True if file is too big for initial preview
|
|
function M.is_big_file(file_path)
|
|
-- Only check file size for early detection - no line limits with dynamic loading
|
|
local stat = vim.uv.fs_stat(file_path)
|
|
if stat and stat.size > M.config.max_size then return true end
|
|
|
|
return false
|
|
end
|
|
|
|
--- Get file information
|
|
--- @param file_path string Path to the file
|
|
--- @return table | nil File information
|
|
function M.get_file_info(file_path)
|
|
local stat = vim.uv.fs_stat(file_path)
|
|
if not stat then return nil end
|
|
|
|
local info = {
|
|
name = vim.fn.fnamemodify(file_path, ':t'),
|
|
path = file_path,
|
|
size = stat.size,
|
|
modified = stat.mtime.sec,
|
|
accessed = stat.atime.sec,
|
|
type = stat.type,
|
|
}
|
|
|
|
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
|
info.filetype = utils.detect_filetype(file_path) or 'text'
|
|
info.size_formatted = utils.format_file_size(info.size)
|
|
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
|
|
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
|
|
|
|
return info
|
|
end
|
|
|
|
--- Preview a regular file
|
|
--- @param file_path string Path to the file
|
|
--- @param bufnr number Buffer number for preview
|
|
--- @return boolean Success status
|
|
function M.preview_file(file_path, bufnr)
|
|
-- Early size detection to prevent memory issues
|
|
if M.is_big_file(file_path) then
|
|
local info = M.get_file_info(file_path)
|
|
local lines = {
|
|
'File too large for preview',
|
|
string.format(
|
|
'Size: %s (max: %s)',
|
|
info and info.size_formatted or 'Unknown',
|
|
string.format('%.1fMB', M.config.max_size / 1024 / 1024)
|
|
),
|
|
'',
|
|
'Use a text editor to view this file.',
|
|
}
|
|
set_buffer_lines(bufnr, lines)
|
|
return true
|
|
end
|
|
|
|
local info = M.get_file_info(file_path)
|
|
if not info then return false end
|
|
|
|
-- if the buffer is already opened for this file we reuse the buffer directly
|
|
local existing_bufnr = find_existing_buffer(file_path)
|
|
|
|
if existing_bufnr then
|
|
local success = link_buffer_content(existing_bufnr, bufnr)
|
|
if success then
|
|
local file_config = M.get_file_config(file_path)
|
|
|
|
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
|
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
|
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
|
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
|
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
|
|
end
|
|
|
|
M.state.scroll_offset = 0
|
|
|
|
-- Apply location highlighting if available (delayed to ensure buffer is ready)
|
|
local gen = M.state.preview_generation
|
|
vim.schedule(function()
|
|
if M.state.preview_generation == gen then M.apply_location_highlighting(bufnr) end
|
|
end)
|
|
|
|
return true
|
|
end
|
|
end
|
|
|
|
M.state.current_file = file_path
|
|
M.state.bufnr = bufnr
|
|
local generation = M.state.preview_generation
|
|
|
|
read_file_streaming_async(file_path, function(content, err, loading_more)
|
|
if M.state.preview_generation ~= generation then
|
|
-- Preview moved on to a different file, discard
|
|
cleanup_file_operation()
|
|
return
|
|
end
|
|
|
|
if err or not content then
|
|
if M.state.current_file == file_path then
|
|
set_buffer_lines(bufnr, { 'Failed to load file: ' .. (err or 'unknown error') })
|
|
end
|
|
return
|
|
end
|
|
|
|
if M.state.current_file == file_path then
|
|
-- Guard against buffer being destroyed while async read was in-flight
|
|
if not vim.api.nvim_buf_is_valid(bufnr) then return end
|
|
|
|
M.clear_preview_visual_state(bufnr)
|
|
set_buffer_lines(bufnr, content)
|
|
|
|
local file_config = M.get_file_config(file_path)
|
|
local ok = set_preview_filetype(bufnr, info.filetype)
|
|
if not ok then
|
|
show_preview_unavailable(bufnr)
|
|
return
|
|
end
|
|
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
|
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
|
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
|
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
|
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
|
|
end
|
|
|
|
M.state.content_height = #content
|
|
M.state.scroll_offset = 0
|
|
|
|
-- Apply location highlighting if available (delayed to ensure buffer is ready).
|
|
-- Skip when more content is being loaded asynchronously to reach the target line —
|
|
-- ensure_content_loaded_async will re-apply highlighting once the target is in the buffer.
|
|
if not loading_more then
|
|
vim.schedule(function()
|
|
if M.state.preview_generation == generation then M.apply_location_highlighting(bufnr) end
|
|
end)
|
|
end
|
|
end
|
|
end)
|
|
|
|
return true
|
|
end
|
|
|
|
-- Hex preview highlight support: dynamically create hl groups from "#rrggbb"
|
|
local hex_ns = nil
|
|
local hex_hl_cache = {}
|
|
|
|
local function ensure_hex_ns()
|
|
if not hex_ns then hex_ns = vim.api.nvim_create_namespace('fff_hex_preview') end
|
|
return hex_ns
|
|
end
|
|
|
|
local function get_hex_hl_group(hex_color)
|
|
local cached = hex_hl_cache[hex_color]
|
|
if cached then return cached end
|
|
local group = 'FffHex_' .. hex_color:sub(2)
|
|
vim.api.nvim_set_hl(0, group, { fg = hex_color })
|
|
hex_hl_cache[hex_color] = group
|
|
return group
|
|
end
|
|
|
|
--- Apply hex highlight spans to a buffer
|
|
--- @param bufnr number Buffer number
|
|
--- @param highlights table Array of {line_0idx, col_start, col_end, "#rrggbb"}
|
|
--- @param line_offset number Lines to add to each highlight line index (for header)
|
|
local function apply_hex_highlights(bufnr, highlights, line_offset)
|
|
if not highlights or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
|
local ns = ensure_hex_ns()
|
|
for _, hl in ipairs(highlights) do
|
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hl[1] + line_offset, hl[2], {
|
|
end_col = hl[3],
|
|
hl_group = get_hex_hl_group(hl[4]),
|
|
})
|
|
end
|
|
end
|
|
|
|
--- Load a page of hex dump content from the Rust backend
|
|
--- @param file_path string Path to the binary file
|
|
--- @param byte_offset number Byte offset to start reading from
|
|
--- @return table|nil Result with lines, highlights, has_more, next_offset
|
|
local function load_hex_page(file_path, byte_offset)
|
|
local ok, result = pcall(rust.hex_dump, file_path, byte_offset, 4096)
|
|
if ok and result then return result end
|
|
return nil
|
|
end
|
|
|
|
--- Load more hex content when scrolling near the end of the buffer
|
|
local function load_more_hex_content()
|
|
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
|
if not M.state.has_more_content or not M.state.current_file then return end
|
|
|
|
local current_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
|
local result = load_hex_page(M.state.current_file, M.state.hex_byte_offset)
|
|
if result and result.lines and #result.lines > 0 then
|
|
append_buffer_lines(M.state.bufnr, result.lines)
|
|
M.state.hex_byte_offset = result.next_offset
|
|
M.state.has_more_content = result.has_more
|
|
M.state.content_height = vim.api.nvim_buf_line_count(M.state.bufnr)
|
|
M.state.loaded_lines = M.state.content_height
|
|
apply_hex_highlights(M.state.bufnr, result.highlights, current_lines)
|
|
else
|
|
M.state.has_more_content = false
|
|
end
|
|
end
|
|
|
|
--- Preview a binary file using hexyl-powered hex dump with paging
|
|
--- @param file_path string Path to the file
|
|
--- @param bufnr number Buffer number for preview
|
|
--- @return boolean Success status
|
|
function M.preview_binary_file(file_path, bufnr)
|
|
local info = M.get_file_info(file_path)
|
|
local lines = {}
|
|
|
|
M.state.is_binary_preview = true
|
|
M.state.hex_byte_offset = 0
|
|
|
|
-- Build header synchronously (file -b is fast, typically <10ms)
|
|
if vim.fn.executable('file') == 1 then
|
|
local output = vim.fn.system({ 'file', '-b', file_path })
|
|
if vim.v.shell_error == 0 and output then
|
|
local file_type = output:gsub('\n', '')
|
|
table.insert(lines, 'Binary file: ' .. file_type)
|
|
if info and info.size_formatted then table.insert(lines, 'Size: ' .. info.size_formatted) end
|
|
table.insert(lines, '')
|
|
end
|
|
end
|
|
|
|
local hex_result = load_hex_page(file_path, 0)
|
|
if hex_result and hex_result.lines then
|
|
for _, hex_line in ipairs(hex_result.lines) do
|
|
table.insert(lines, hex_line)
|
|
end
|
|
M.state.hex_byte_offset = hex_result.next_offset
|
|
M.state.has_more_content = hex_result.has_more
|
|
end
|
|
|
|
set_buffer_lines(bufnr, lines)
|
|
local ok = set_preview_filetype(bufnr, 'text')
|
|
if not ok then
|
|
show_preview_unavailable(bufnr)
|
|
return false
|
|
end
|
|
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
|
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
|
|
|
M.state.content_height = #lines
|
|
M.state.loaded_lines = #lines
|
|
|
|
if hex_result and hex_result.highlights then
|
|
local header_lines = #lines - (hex_result.lines and #hex_result.lines or 0)
|
|
apply_hex_highlights(bufnr, hex_result.highlights, header_lines)
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
--- Get file-specific configuration
|
|
--- @param file_path string Path to the file
|
|
--- @return table Configuration for the file
|
|
function M.get_file_config(file_path)
|
|
if not M.config or not M.config.filetypes then return {} end
|
|
|
|
local filetype = utils.detect_filetype(file_path) or 'text'
|
|
return M.config.filetypes[filetype] or {}
|
|
end
|
|
|
|
--- @param file_path string Path to the file or directory
|
|
--- @param bufnr number Buffer number for preview
|
|
--- @param location table|nil Optional location data for highlighting
|
|
--- @param is_binary boolean|nil Whether the file is binary (from Rust indexer)
|
|
--- @return boolean if the preview was successful
|
|
function M.preview(file_path, bufnr, location, is_binary)
|
|
if not file_path or file_path == '' then return false end
|
|
|
|
-- Bump generation to invalidate any in-flight async callbacks from previous previews
|
|
M.state.preview_generation = M.state.preview_generation + 1
|
|
|
|
if M.state.file_handle then
|
|
---@diagnostic disable-next-line: undefined-field
|
|
M.state.file_handle:close()
|
|
M.state.file_handle = nil
|
|
end
|
|
|
|
M.state.loaded_lines = 0
|
|
M.state.total_file_lines = nil
|
|
M.state.has_more_content = true
|
|
M.state.is_loading = false
|
|
M.state.hex_byte_offset = 0
|
|
|
|
M.state.is_binary_preview = false
|
|
|
|
M.state.current_file = file_path
|
|
M.state.bufnr = bufnr
|
|
M.state.location = location
|
|
|
|
if image.is_image(file_path) then
|
|
M.clear_buffer(bufnr)
|
|
|
|
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return false end
|
|
|
|
return image.display_image(file_path, bufnr)
|
|
elseif is_binary then
|
|
return M.preview_binary_file(file_path, bufnr)
|
|
else
|
|
return M.preview_file(file_path, bufnr)
|
|
end
|
|
end
|
|
|
|
function M.scroll(lines)
|
|
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
|
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return end
|
|
|
|
local win_height = vim.api.nvim_win_get_height(M.state.winid)
|
|
local current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
|
|
|
local current_offset = M.state.scroll_offset or 0
|
|
local new_offset = current_offset + lines
|
|
|
|
-- If scrolling down and approaching end of loaded content, try to load more
|
|
if lines > 0 and not M.state.is_loading then
|
|
local target_line = new_offset + win_height
|
|
local buffer_needed = target_line + 20
|
|
|
|
if current_buffer_lines < buffer_needed and M.state.has_more_content then
|
|
if M.state.is_binary_preview then
|
|
load_more_hex_content()
|
|
-- Re-read line count after loading more
|
|
current_buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
|
else
|
|
ensure_content_loaded_async(target_line)
|
|
end
|
|
end
|
|
end
|
|
|
|
local content_height = current_buffer_lines
|
|
local half_screen = math.floor(win_height / 2)
|
|
local max_scroll = math.max(0, content_height + half_screen - win_height)
|
|
|
|
new_offset = math.max(0, math.min(max_scroll, new_offset))
|
|
if new_offset ~= current_offset then
|
|
M.state.scroll_offset = new_offset
|
|
M.state.content_height = content_height
|
|
|
|
local target_line = math.min(content_height, math.max(1, new_offset + 1))
|
|
|
|
vim.api.nvim_win_call(M.state.winid, function()
|
|
vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 })
|
|
vim.cmd('normal! zt')
|
|
end)
|
|
end
|
|
end
|
|
|
|
--- Set preview window
|
|
--- @param winid number Window ID for the preview
|
|
function M.set_preview_window(winid) M.state.winid = winid end
|
|
|
|
local function resolve_absolute_path(rel)
|
|
if not rel or rel == '' then return nil end
|
|
if rel:sub(1, 1) == '/' then return rel end
|
|
local base = require('fff.conf').get().base_path
|
|
if base and base ~= '' then return (base:sub(-1) == '/') and (base .. rel) or (base .. '/' .. rel) end
|
|
return vim.fn.fnamemodify(rel, ':p')
|
|
end
|
|
|
|
local function build_file_info_data(file, file_metadata, file_index)
|
|
local conf = require('fff.conf').get()
|
|
local is_grep = file.line_number ~= nil
|
|
local sections = (conf.debug and conf.debug.show_file_info)
|
|
or { file_info = true, score_breakdown = true, timings = true, full_path = true }
|
|
|
|
-- Grep matches don't have a per-file fuzzy score, so the breakdown would
|
|
-- be all "N/A". Skip it but keep the rest of the panel identical.
|
|
if is_grep then sections = vim.tbl_extend('force', sections, { score_breakdown = false }) end
|
|
|
|
local abs_path = resolve_absolute_path(file.relative_path)
|
|
local times_opened = 0
|
|
if abs_path then
|
|
local ok, n = pcall(rust.get_file_access_count, abs_path)
|
|
if ok and type(n) == 'number' then times_opened = n end
|
|
end
|
|
|
|
local file_view = {
|
|
relative_path = file.relative_path,
|
|
absolute_path = abs_path or file.absolute_path or file_metadata.path,
|
|
size_formatted = file_metadata.size_formatted,
|
|
filetype = file_metadata.filetype or 'text',
|
|
git_status = file.git_status or 'clean',
|
|
access_frecency_score = file.access_frecency_score or 0,
|
|
modification_frecency_score = file.modification_frecency_score or 0,
|
|
times_opened = times_opened,
|
|
modified_formatted = file_metadata.modified_formatted,
|
|
accessed_formatted = file_metadata.accessed_formatted,
|
|
}
|
|
|
|
local score = nil
|
|
if not is_grep and file_index then score = file_picker.get_file_score(file_index) end
|
|
|
|
return {
|
|
file = file_view,
|
|
score = score,
|
|
sections = sections,
|
|
hls = conf.hl,
|
|
}
|
|
end
|
|
|
|
local function apply_file_info_extmarks(bufnr, extmarks)
|
|
vim.api.nvim_buf_clear_namespace(bufnr, M.file_info_ns, 0, -1)
|
|
for _, m in ipairs(extmarks) do
|
|
local opts = {}
|
|
if m.hl_group then
|
|
opts.hl_group = m.hl_group
|
|
if m.end_col then opts.end_col = m.end_col end
|
|
end
|
|
if m.virt_text then
|
|
opts.virt_text = m.virt_text
|
|
opts.virt_text_pos = m.virt_text_pos or 'overlay'
|
|
end
|
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, M.file_info_ns, m.row, m.col or 0, opts)
|
|
end
|
|
end
|
|
|
|
local function resize_file_info(file_info_win, target_h, preview_win)
|
|
if not (file_info_win and vim.api.nvim_win_is_valid(file_info_win)) then return end
|
|
local current_h = vim.api.nvim_win_get_height(file_info_win)
|
|
local delta = target_h - current_h
|
|
if delta == 0 then return end
|
|
pcall(vim.api.nvim_win_set_config, file_info_win, { height = target_h })
|
|
if preview_win and vim.api.nvim_win_is_valid(preview_win) then
|
|
local cfg = vim.api.nvim_win_get_config(preview_win)
|
|
if cfg.row and cfg.height then
|
|
local new_row = (type(cfg.row) == 'table' and cfg.row[false] or cfg.row) + delta
|
|
pcall(vim.api.nvim_win_set_config, preview_win, {
|
|
relative = cfg.relative or 'editor',
|
|
row = new_row,
|
|
col = cfg.col,
|
|
width = cfg.width,
|
|
height = math.max(3, cfg.height - delta),
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Update file info buffer
|
|
--- @param file table
|
|
--- @param bufnr number
|
|
--- @param file_index number|nil
|
|
--- @param preview_win number|nil Preview window — shifted to keep flush with file_info
|
|
--- @return boolean
|
|
function M.update_file_info_buffer(file, bufnr, file_index, preview_win)
|
|
if not file then
|
|
set_buffer_lines(bufnr, { 'No file selected' })
|
|
return false
|
|
end
|
|
|
|
local info = M.get_file_info(file.relative_path)
|
|
if not info then
|
|
set_buffer_lines(bufnr, { 'File info unavailable' })
|
|
return false
|
|
end
|
|
|
|
local width = 80
|
|
local target_win
|
|
local wins = vim.fn.win_findbuf(bufnr)
|
|
for _, win in ipairs(wins) do
|
|
if vim.api.nvim_win_is_valid(win) then
|
|
width = vim.api.nvim_win_get_width(win)
|
|
target_win = win
|
|
break
|
|
end
|
|
end
|
|
|
|
local data = build_file_info_data(file, info, file_index)
|
|
data.width = width
|
|
local result = file_info_renderer.build(data)
|
|
|
|
set_buffer_lines(bufnr, result.lines)
|
|
apply_file_info_extmarks(bufnr, result.extmarks)
|
|
|
|
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
|
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
|
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
|
|
|
for _, win in ipairs(wins) do
|
|
if vim.api.nvim_win_is_valid(win) then vim.api.nvim_set_option_value('wrap', false, { win = win }) end
|
|
end
|
|
|
|
if result.height > 0 then resize_file_info(target_win, result.height, preview_win) end
|
|
|
|
return true
|
|
end
|
|
|
|
function M.clear_preview_visual_state(bufnr)
|
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
|
|
|
-- Only clear visual state, don't affect buffer functionality
|
|
-- Clear namespaces and extmarks for this buffer only
|
|
vim.api.nvim_buf_clear_namespace(bufnr, -1, 0, -1)
|
|
|
|
-- Clear location highlights
|
|
if M.state.location_namespace then location_utils.clear_location_highlights(bufnr, M.state.location_namespace) end
|
|
|
|
local wins = vim.fn.win_findbuf(bufnr)
|
|
|
|
for _, win in ipairs(wins) do
|
|
if vim.api.nvim_win_is_valid(win) then
|
|
-- Reset folds
|
|
pcall(vim.api.nvim_win_call, win, function()
|
|
if vim.fn.has('folding') == 1 then
|
|
vim.cmd('normal! zE') -- eliminate all folds
|
|
vim.opt_local.foldenable = false -- disable folding
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
image.clear_buffer_images(bufnr)
|
|
end
|
|
|
|
function M.clear_buffer(bufnr)
|
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
|
|
|
cleanup_file_operation()
|
|
M.clear_preview_visual_state(bufnr)
|
|
|
|
pcall(vim.treesitter.stop, bufnr)
|
|
|
|
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
|
set_preview_filetype(bufnr, '')
|
|
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
|
|
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
|
|
|
set_buffer_lines(bufnr, {})
|
|
end
|
|
|
|
function M.clear()
|
|
-- Bump generation to invalidate any in-flight async callbacks
|
|
M.state.preview_generation = M.state.preview_generation + 1
|
|
|
|
cleanup_file_operation()
|
|
|
|
M.state.loaded_lines = 0
|
|
M.state.total_file_lines = nil
|
|
M.state.has_more_content = true
|
|
M.state.is_loading = false
|
|
|
|
if M.state.bufnr and vim.api.nvim_buf_is_valid(M.state.bufnr) then M.clear_buffer(M.state.bufnr) end
|
|
|
|
M.state.current_file = nil
|
|
M.state.scroll_offset = 0
|
|
M.state.content_height = 0
|
|
M.state.location = nil
|
|
M.state.is_binary_preview = false
|
|
M.state.hex_byte_offset = 0
|
|
end
|
|
|
|
--- Apply location highlighting to the preview buffer
|
|
--- @param bufnr number Buffer number
|
|
function M.apply_location_highlighting(bufnr)
|
|
-- Ensure namespace is created
|
|
if not M.state.location_namespace then
|
|
M.state.location_namespace = vim.api.nvim_create_namespace('fff_preview_location')
|
|
end
|
|
|
|
-- Always clear previous location highlights first
|
|
if vim.api.nvim_buf_is_valid(bufnr) then
|
|
location_utils.clear_location_highlights(bufnr, M.state.location_namespace)
|
|
end
|
|
|
|
if not M.state.location then return end
|
|
|
|
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
|
|
|
|
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
|
local target_line = location_utils.get_target_line(M.state.location)
|
|
if target_line then
|
|
local buffer_lines = vim.api.nvim_buf_line_count(bufnr)
|
|
if target_line > buffer_lines and M.state.has_more_content then
|
|
-- Target line is beyond loaded content — load more first.
|
|
-- ensure_content_loaded_async will re-apply highlighting when done.
|
|
ensure_content_loaded_async(target_line)
|
|
return
|
|
end
|
|
M.scroll_to_line(target_line)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Scroll preview to a specific line
|
|
--- @param line number Target line number (1-indexed)
|
|
function M.scroll_to_line(line)
|
|
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return end
|
|
if not M.state.bufnr or not vim.api.nvim_buf_is_valid(M.state.bufnr) then return end
|
|
|
|
local win_height = vim.api.nvim_win_get_height(M.state.winid)
|
|
local buffer_lines = vim.api.nvim_buf_line_count(M.state.bufnr)
|
|
local target_line = math.max(1, math.min(line, buffer_lines))
|
|
|
|
local half_screen = math.floor(win_height / 2)
|
|
local new_offset = math.max(0, target_line - half_screen)
|
|
|
|
M.state.scroll_offset = new_offset
|
|
pcall(vim.api.nvim_win_call, M.state.winid, function()
|
|
vim.api.nvim_win_set_cursor(M.state.winid, { target_line, 0 })
|
|
vim.cmd('normal! zt')
|
|
end)
|
|
end
|
|
|
|
return M
|