chore: import upstream snapshot with attribution
Python CI / Python bindings (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 1s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 0s
Rust CI / cargo clippy (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 1s
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 1s
Spelling / Spell Check with Typos (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 0s
Rust CI / cargo fmt (push) Failing after 0s
Stylua / Check lua files using Stylua (push) Failing after 1s
Lua CI / lua-language-server type check (push) Failing after 12m29s
e2e Tests / e2e (alpine-musl) (push) Successful in 57m31s
e2e Tests / e2e (macos-latest) (push) Has been cancelled
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Rust CI / Test (windows-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 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
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 / Fuzz Tests (windows-latest) (push) Has been cancelled
Rust CI / Test (macos-latest) (push) Has been cancelled
Rust CI / Fuzz Tests (macos-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:16 +08:00
commit d5f207424b
328 changed files with 87463 additions and 0 deletions
+415
View File
@@ -0,0 +1,415 @@
local M = {}
---@class FFFFileInfoExtmark
---@field row integer
---@field col integer
---@field end_col integer|nil
---@field hl_group string|nil
---@class FFFFileInfoResult
---@field lines string[]
---@field extmarks FFFFileInfoExtmark[]
---@field height integer
---@class FFFFileInfoSections
---@field file_info boolean
---@field score_breakdown boolean
---@field timings boolean
---@field full_path boolean
---@class FFFFileInfoFile
---@field relative_path string
---@field absolute_path string|nil
---@field size_formatted string
---@field filetype string
---@field git_status string
---@field access_frecency_score integer
---@field modification_frecency_score integer
---@field times_opened integer
---@field modified_formatted string
---@field accessed_formatted string
---@class FFFFileInfoScore
---@field total integer
---@field match_type string
---@field base_score integer
---@field filename_bonus integer
---@field special_filename_bonus integer
---@field frecency_boost integer
---@field combo_match_boost integer
---@field distance_penalty integer
---@field current_file_penalty integer
local Builder = {}
Builder.__index = Builder
function Builder.new() return setmetatable({ lines = {}, extmarks = {} }, Builder) end
function Builder:add_line(text) table.insert(self.lines, text or '') end
function Builder:add_hl(row, col, end_col, hl_group)
if not hl_group or hl_group == '' then return end
table.insert(self.extmarks, { row = row, col = col, end_col = end_col, hl_group = hl_group })
end
-- Section header: `─ <label> ` then dashes filling the remaining width.
function Builder:add_section_header(label, hls, width)
local row = #self.lines
local prefix = ''
local left_text = prefix .. label .. ' '
local fill_chars = math.max(1, (width or 0) - vim.fn.strdisplaywidth(left_text))
local fill = string.rep('', fill_chars)
self:add_line(left_text .. fill)
self:add_hl(row, 0, #prefix, hls.file_info_separator)
self:add_hl(row, #prefix, #prefix + #label, hls.file_info_section)
self:add_hl(row, #left_text, #left_text + #fill, hls.file_info_separator)
end
local GIT_HL_KEYS = {
staged_new = 'git_staged',
staged_modified = 'git_staged',
staged_deleted = 'git_staged',
modified = 'git_modified',
deleted = 'git_deleted',
renamed = 'git_renamed',
untracked = 'git_untracked',
ignored = 'git_ignored',
}
local function git_hl(hls, status)
local key = GIT_HL_KEYS[status]
return (key and hls[key]) or hls.file_info_value
end
local GIT_LABELS = {
staged_new = 'staged (new)',
staged_modified = 'staged',
staged_deleted = 'staged (del)',
untracked = 'untracked',
modified = 'modified',
deleted = 'deleted',
renamed = 'renamed',
ignored = 'ignored',
clean = 'clean',
clear = '',
unknown = '?',
}
local function frecency_value(file)
return string.format('acc %d / mod %d', file.access_frecency_score or 0, file.modification_frecency_score or 0)
end
local function signed(n)
n = n or 0
if n > 0 then return '+' .. tostring(n) end
return tostring(n)
end
-- Pad to `width` cols, truncate with '…' if longer.
local function pad(text, width)
text = text or ''
local w = vim.fn.strdisplaywidth(text)
if w == width then return text end
if w < width then return text .. string.rep(' ', width - w) end
if width <= 1 then return text:sub(1, math.max(0, width)) end
return text:sub(1, width - 1) .. ''
end
-- 4-cell grid row: [indent][label1][value1][label2][value2]. `value2_max`
-- clamps the trailing cell so the row doesn't overflow.
local function add_grid_row(b, indent_w, label1_w, value1_w, label2_w, cells, hls_by_cell, value2_max)
local row = #b.lines
local l1 = pad(cells[1] or '', label1_w)
local v1 = pad(cells[2] or '', value1_w)
local l2 = pad(cells[3] or '', label2_w)
local v2_raw = cells[4] or ''
local v2 = (value2_max and value2_max > 0) and pad(v2_raw, value2_max) or v2_raw
b:add_line(string.rep(' ', indent_w) .. l1 .. v1 .. l2 .. v2)
local pos = indent_w
if cells[1] and cells[1] ~= '' then b:add_hl(row, pos, pos + #cells[1], hls_by_cell[1]) end
pos = pos + #l1
if cells[2] and cells[2] ~= '' then b:add_hl(row, pos, pos + #cells[2], hls_by_cell[2]) end
pos = pos + #v1
if cells[3] and cells[3] ~= '' then b:add_hl(row, pos, pos + #cells[3], hls_by_cell[3]) end
pos = pos + #l2
if v2 ~= '' then
local content_len = math.min(#v2_raw, value2_max or math.huge)
b:add_hl(row, pos, pos + content_len, hls_by_cell[4])
end
end
-- hardcoded because specifically optimized to make every section look good
local W = {
indent = 1,
label1 = 6,
value1 = 15,
label2 = 9, -- score/match section trailing label
file_label2 = 8, -- file overview trailing label ("Opened")
timings_label = 10,
timings_inline_width = 62,
}
W.grid_prefix = W.indent + W.label1 + W.value1 + W.label2
local function format_opened(count)
if count == 0 then return 'never' end
if count == 1 then return '1 time last 30 days' end
if count >= 128 then return '128+ times last 30 days' end
return count .. ' times last 30 days'
end
local function render_file_overview_section(b, file, hls, width)
local git_status = file.git_status or 'clean'
local git_value = GIT_LABELS[git_status] or git_status
if git_value == '' then git_value = 'clean' end
local opened_count = file.times_opened or 0
local v2_max = math.max(4, width - W.indent - W.label1 - W.value1 - W.file_label2)
local function row(label1, value1, hl1, label2, value2, hl2)
add_grid_row(
b,
W.indent,
W.label1,
W.value1,
W.file_label2,
{ label1, value1, label2, value2 },
{ hls.file_info_label, hl1, hls.file_info_label, hl2 },
v2_max
)
end
row('Size', file.size_formatted or 'N/A', hls.file_info_size, 'Type', file.filetype or 'text', hls.file_info_type)
row(
'Git',
git_value,
git_hl(hls, git_status),
'Opened',
format_opened(opened_count),
opened_count > 0 and hls.file_info_value or hls.file_info_value_dim
)
end
local function render_score_section(b, score, file, hls, width)
local v2_max = math.max(4, width - W.grid_prefix)
if not score then
add_grid_row(
b,
W.indent,
W.label1,
W.value1,
W.label2,
{ 'Total', 'N/A', '', '' },
{ hls.file_info_label, hls.file_info_value_dim, hls.file_info_label, hls.file_info_value },
v2_max
)
return
end
local total_str = tostring(score.total or 0)
local mt_str = score.match_type or 'unknown'
local frec_str = file and frecency_value(file) or ''
local indent = string.rep(' ', W.indent)
local total_label = 'Total '
local frec_label = ' Frecency '
local left = indent .. total_label .. total_str .. ' ' .. mt_str
local right = (frec_str ~= '') and (frec_label .. frec_str) or ''
local total_w = vim.fn.strdisplaywidth(left) + vim.fn.strdisplaywidth(right)
if total_w > width then
local overflow = total_w - width + 1
local mt_keep = math.max(1, vim.fn.strdisplaywidth(mt_str) - overflow)
mt_str = mt_str:sub(1, mt_keep) .. ''
left = indent .. total_label .. total_str .. ' ' .. mt_str
end
local row = #b.lines
b:add_line(left .. right)
b:add_hl(row, W.indent, W.indent + 5, hls.file_info_label)
local total_byte = W.indent + #total_label
b:add_hl(row, total_byte, total_byte + #total_str, hls.file_info_total_score)
local mt_byte = total_byte + #total_str + 1
b:add_hl(row, mt_byte, mt_byte + #mt_str, hls.file_info_match_type)
if right ~= '' then
local right_byte = #left
b:add_hl(row, right_byte + 2, right_byte + 2 + 8, hls.file_info_label)
b:add_hl(row, right_byte + #frec_label, right_byte + #right, hls.file_info_value)
end
local total_pen = (score.distance_penalty or 0) + (score.current_file_penalty or 0)
local pos_hl = hls.file_info_score_pos
local neg_hl = hls.file_info_score_neg
local val_hl = hls.file_info_value
local function bonus_hl(n) return (n or 0) > 0 and pos_hl or val_hl end
local function mod_hl(n) return (n or 0) >= 0 and pos_hl or neg_hl end
local segments = {
{ 'base ' .. (score.base_score or 0), val_hl },
{ ' +name ' .. (score.filename_bonus or 0), bonus_hl(score.filename_bonus) },
{ ' +special ' .. (score.special_filename_bonus or 0), bonus_hl(score.special_filename_bonus) },
{ ' +frec ' .. signed(score.frecency_boost or 0), mod_hl(score.frecency_boost) },
{ ' +combo ' .. signed(score.combo_match_boost or 0), bonus_hl(score.combo_match_boost) },
{ ' penalty ' .. total_pen, total_pen > 0 and neg_hl or val_hl },
}
local row2 = #b.lines
local line_text = indent
local hl_ranges = {}
for _, seg in ipairs(segments) do
local from = #line_text
line_text = line_text .. seg[1]
table.insert(hl_ranges, { from, #line_text, seg[2] })
end
if vim.fn.strdisplaywidth(line_text) > width then line_text = line_text:sub(1, width - 1) .. '' end
b:add_line(line_text)
for _, r in ipairs(hl_ranges) do
if r[1] < #line_text then b:add_hl(row2, r[1], math.min(r[2], #line_text), r[3]) end
end
end
local function render_score(b, score, file, hls, width)
b:add_section_header('Score', hls, width)
render_score_section(b, score, file, hls, width)
end
-- `timings_opt` may be a boolean or `{ modified = bool, accessed = bool }`.
local function render_timings_section(b, file, hls, width, timings_opt)
local show_modified, show_accessed
if type(timings_opt) == 'table' then
show_modified = timings_opt.modified ~= false
show_accessed = timings_opt.accessed ~= false
else
show_modified = true
show_accessed = true
end
if not show_modified and not show_accessed then return end
b:add_section_header('Timings', hls, width)
local label_hl = hls.file_info_label
local value_hl = hls.file_info_value
local both_inline = show_modified and show_accessed and width >= W.timings_inline_width
if both_inline then
add_grid_row(
b,
W.indent,
W.timings_label,
22,
W.timings_label,
{ 'Modified', file.modified_formatted or 'N/A', 'Accessed', file.accessed_formatted or 'N/A' },
{ label_hl, value_hl, label_hl, value_hl }
)
return
end
if show_modified then
add_grid_row(
b,
W.indent,
W.timings_label,
19,
0,
{ 'Modified', file.modified_formatted or 'N/A', '', '' },
{ label_hl, value_hl, label_hl, value_hl }
)
end
if show_accessed then
add_grid_row(
b,
W.indent,
W.timings_label,
19,
0,
{ 'Accessed', file.accessed_formatted or 'N/A', '', '' },
{ label_hl, value_hl, label_hl, value_hl }
)
end
end
-- Manual path split — buffer is nowrap so wrap would misalign extmarks.
local function render_path_section(b, file, hls, width)
b:add_section_header('Path', hls, width)
local path = file.relative_path or file.absolute_path or ''
local indent = string.rep(' ', W.indent)
local content_width = math.max(10, width - W.indent - 1)
local path_hl = hls.file_info_path
if #path <= content_width then
local row = #b.lines
b:add_line(indent .. path)
b:add_hl(row, W.indent, W.indent + #path, path_hl)
else
local first = path:sub(1, content_width)
local rest = path:sub(content_width + 1)
if #rest > content_width then rest = '' .. rest:sub(-content_width + 1) end
local r1 = #b.lines
b:add_line(indent .. first)
b:add_hl(r1, W.indent, W.indent + #first, path_hl)
local r2 = #b.lines
b:add_line(indent .. rest)
b:add_hl(r2, W.indent, W.indent + #rest, path_hl)
end
end
---@class FFFFileInfoInput
---@field file FFFFileInfoFile
---@field score FFFFileInfoScore|nil
---@field width integer
---@field sections FFFFileInfoSections
---@field hls table The `hl` block from FffConfig.
---@param input FFFFileInfoInput
---@return FFFFileInfoResult
function M.build(input)
local width = input.width or 80
local sections = input.sections or {}
local hls = input.hls
local file = input.file
local b = Builder.new()
if sections.file_info ~= false then render_file_overview_section(b, file, hls, width) end
if sections.score_breakdown ~= false then render_score(b, input.score, file, hls, width) end
if sections.timings ~= false then render_timings_section(b, file, hls, width, sections.timings) end
if sections.full_path ~= false then render_path_section(b, file, hls, width) end
return { lines = b.lines, extmarks = b.extmarks, height = #b.lines }
end
--- @param sections table|boolean
--- @param panel_width integer
--- @return integer
function M.calculate_required_height(sections, panel_width)
if sections == false then return 0 end
local s = sections
if type(s) == 'boolean' then
s = { file_info = s, score_breakdown = s, timings = s, full_path = s }
elseif type(s) ~= 'table' then
s = {}
end
local total = 0
if s.file_info ~= false then total = total + 2 end
if s.score_breakdown ~= false then total = total + 3 end
local t = s.timings
if t ~= false and t ~= nil then
local show_modified, show_accessed
if type(t) == 'table' then
show_modified = t.modified ~= false
show_accessed = t.accessed ~= false
else
show_modified = true
show_accessed = true
end
local visible = (show_modified and 1 or 0) + (show_accessed and 1 or 0)
if visible > 0 then
local inline = visible == 2 and (panel_width or 0) >= W.timings_inline_width
total = total + 1 + (inline and 1 or visible)
end
end
if s.full_path ~= false and s.full_path ~= nil then total = total + 2 end
return total
end
return M
+91
View File
@@ -0,0 +1,91 @@
local M = {}
local icon_providers = {
'nvim-web-devicons',
'mini.icons',
}
M.provider = nil
M.provider_name = nil
M.setup_attempted = false
M.setup_failed = false
function M.setup()
if M.provider_name then return true end
if M.setup_failed then return false end
M.setup_attempted = true
for _, provider_name in ipairs(icon_providers) do
local ok, provider = pcall(require, provider_name)
if ok then
M.provider = provider
M.provider_name = provider_name
return true
end
end
M.setup_failed = true
return false
end
--- Get icon for a directory
--- @param dirname string The directory name
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_directory_icon(dirname)
if not M.setup() then return nil, nil end
local basename = vim.fn.fnamemodify(dirname, ':t')
if M.provider_name == 'nvim-web-devicons' then
if M.provider.get_icon then
local icon, hl = M.provider.get_icon(basename, nil, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
end
elseif M.provider_name == 'mini.icons' then
if M.provider.get then
local icon, hl, _ = M.provider.get('directory', basename)
if icon and icon ~= '' and hl then return icon, hl end
end
end
return nil, nil
end
--- Get icon for a file
--- @param filename string The filename
--- @param extension string The file extension (without dot)
--- @param is_directory boolean Whether this is a directory
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_icon(filename, extension, is_directory)
if not M.setup() then return nil, nil end
if is_directory then return M.get_directory_icon(filename) end
if M.provider_name == 'nvim-web-devicons' then
local icon, hl = M.provider.get_icon(filename, extension, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
elseif M.provider_name == 'mini.icons' then
local icon, hl, _ = M.provider.get('file', filename)
if icon and icon ~= '' and hl then return icon, hl end
end
return nil, nil
end
--- Check if directories are supported by current provider
--- @return boolean True if directory icons are supported
function M.supports_directories() return M.setup() and M.provider_name ~= nil end
--- Get provider info for debugging
--- @return table Provider information
function M.get_provider_info()
M.setup()
return {
name = M.provider_name or 'none',
available = M.provider ~= nil,
supports_directories = M.supports_directories(),
}
end
return M
+264
View File
@@ -0,0 +1,264 @@
local utils = require('fff.utils')
local M = {}
local function get_main_config()
local main = require('fff.main')
return main.config
end
local active_placements = {} ---@type table<number, any>
local loading_jobs = {} ---@type table<number, {metadata_job?: any}>
local function reserve_image_buffer_space(bufnr, metadata_lines_count)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
local win = vim.fn.bufwinid(bufnr)
local buffer_height = win ~= -1 and vim.api.nvim_win_get_height(win) or 24
local lines_for_image = math.max(buffer_height - metadata_lines_count - 2, 5)
local buffer_lines = {}
for _ = 1, metadata_lines_count do
table.insert(buffer_lines, '')
end
for _ = 1, lines_for_image do
table.insert(buffer_lines, '')
end
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
return metadata_lines_count or 2
end
local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
reserved_lines_count = reserved_lines_count or 2
local metadata_lines = {}
for i = 1, reserved_lines_count do
metadata_lines[i] = info_lines[i] or ''
end
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
end
local function identify_image_lines_async(file_path, bufnr, callback)
local stat = vim.uv.fs_stat(file_path)
local size_str = stat and utils.format_file_size(stat.size) or 'Unknown'
local initial_info_lines = { ' Size: ' .. size_str }
if vim.fn.executable('identify') == 0 then
callback(initial_info_lines)
return
end
callback(initial_info_lines)
local config = get_main_config()
local format_str = config and config.preview and config.preview.imagemagick_info_format_str
or '%m: %wx%h, %[colorspace], %q-bit'
local cmd = { 'identify', '-format', format_str, file_path }
-- Cancel any previous metadata job
if loading_jobs[bufnr] and loading_jobs[bufnr].metadata_job then
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
end
loading_jobs[bufnr] = loading_jobs[bufnr] or {}
loading_jobs[bufnr].metadata_job = vim.system(cmd, { text = true }, function(result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
local enhanced_info_lines = vim.deepcopy(initial_info_lines)
if result.code == 0 and result.stdout and result.stdout ~= '' then
local magick_info = ' ' .. result.stdout:gsub('\n', '')
table.insert(enhanced_info_lines, magick_info)
end
callback(enhanced_info_lines)
end)
end)
end
local IMAGE_EXTENSIONS = {
'.jpg',
'.jpeg',
'.png',
'.gif',
'.bmp',
'.tiff',
'.tif',
'.webp',
'.ico',
'.pdf',
'.ps',
'.eps',
'.heic',
'.avif',
}
--- @param file_path string Path to the file
--- @return boolean True if file is an image
function M.is_image(file_path)
local ext = string.lower(vim.fn.fnamemodify(file_path, ':e'))
if ext == '' then return false end
for _, image_ext in ipairs(IMAGE_EXTENSIONS) do
if '.' .. ext == image_ext then return true end
end
return false
end
function M.clear_buffer_images(bufnr)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
if loading_jobs[bufnr] then
if loading_jobs[bufnr].metadata_job then
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
end
loading_jobs[bufnr] = nil
end
if active_placements[bufnr] then
pcall(active_placements[bufnr].close, active_placements[bufnr])
active_placements[bufnr] = nil
end
local ok, snacks = pcall(require, 'snacks')
if ok and snacks.image and snacks.image.placement then pcall(snacks.image.placement.clean, bufnr) end
pcall(vim.api.nvim_buf_clear_namespace, bufnr, -1, 0, -1)
end
--- Load metadata of the image, displays it and display image in paralallel
--- Fully asynchronous
--- @param file_path string Path to the image file
--- @param bufnr number Buffer number to display in
--- @return boolean
function M.display_image(file_path, bufnr)
local wins = vim.fn.win_findbuf(bufnr)
for _, win in ipairs(wins) do
vim.api.nvim_set_option_value('number', false, { win = win })
end
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
local image_content_starts_at_line = reserved_metadata_lines + 1
identify_image_lines_async(
file_path,
bufnr,
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
)
local ok, snacks = pcall(require, 'snacks')
if not ok then
local error_lines = {
'⚠ Image Preview Unavailable',
'',
'snacks.nvim plugin is not installed or not available.',
}
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
if not snacks.image.supports_terminal() then
local error_lines = {
'⚠ Image Preview Unavailable',
'',
'Terminal does not support image preview.',
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
}
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
if not snacks.image.supports_file(file_path) then
local error_lines = {
'⚠ Unsupported Image Format',
'',
'File format is not supported for image preview.',
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
}
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
return false
end
if snacks.image and snacks.image.placement then
M.clear_buffer_images(bufnr)
vim.schedule(function()
local success, placement = pcall(snacks.image.placement.new, bufnr, file_path, {
pos = { image_content_starts_at_line, 1 },
inline = true,
fit = 'contain',
auto_resize = true,
})
if success and placement then active_placements[bufnr] = placement end
end)
identify_image_lines_async(
file_path,
bufnr,
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
)
return true
end
return false
end
--- Check image preview availability status
--- @return table status { available: boolean, snacks_available: boolean, snacks_image_available: boolean, terminal_supported: boolean, error: string|nil }
function M.get_preview_status()
local status = {
available = false,
snacks_available = false,
snacks_image_available = false,
terminal_supported = false,
error = nil,
}
local ok, snacks = pcall(require, 'snacks')
if not ok then
status.error = 'snacks.nvim not installed'
return status
end
status.snacks_available = true
if not snacks.image then
status.error = 'snacks.image module not available'
return status
end
status.snacks_image_available = true
if not snacks.image.supports_terminal or not snacks.image.supports_terminal() then
status.error = 'terminal does not support image display'
return status
end
status.terminal_supported = true
status.available = true
return status
end
return M
+212
View File
@@ -0,0 +1,212 @@
--- FFF.nvim File Picker - High-performance file picker for Neovim
--- Uses advanced fuzzy search algorithm with frecency scoring
local M = {}
local fuzzy = require('fff.core').ensure_initialized()
M.state = {
initialized = false,
base_path = nil,
last_scan_time = 0,
}
function M.setup()
local config = require('fff.conf').get()
M.state.initialized = true
M.state.base_path = config.base_path
return true
end
--- Trigger scan of files in the current directory (asynchronous)
function M.scan_files()
if not M.state.initialized then return end
local ok, result = pcall(fuzzy.scan_files)
if not ok then
vim.notify('Failed to trigger file scan: ' .. tostring(result), vim.log.levels.ERROR)
return
end
M.state.last_scan_time = os.time()
end
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @param max_results number|nil Maximum number of results (optional)
--- @param max_threads number|nil Maximum number of threads (optional)
--- @param current_file string|nil Path to current file to deprioritize (optional)
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
--- @return table List of matching files
function M.search_files(query, current_file, max_results, max_threads, min_combo_count_override)
-- Delegate to paginated version with offset=0 and limit=max_results
return M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, 0, max_results)
end
--- Search files with pagination support
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @param current_file string|nil Path to current file to deprioritize (optional)
--- @param max_threads number|nil Maximum number of threads to use
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
--- @param page_size number|nil Items per page (nil uses config default)
--- @return table List of matching files
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
local config = require('fff.conf').get()
if not M.state.initialized then return {} end
max_threads = max_threads or config.max_threads or 4
page_index = page_index or 0
page_size = page_size or 0
local min_combo_count = min_combo_count_override
if min_combo_count == nil then min_combo_count = config.history and config.history.min_combo_count or 3 end
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
-- Convert page_index to offset (Rust expects offset in items, not page number)
local offset = page_index * page_size
local ok, search_result = pcall(
fuzzy.fuzzy_search_files,
query,
max_threads,
current_file,
combo_boost_score_multiplier,
min_combo_count,
offset,
page_size
)
if not ok then
vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR)
return {}
end
M.state.last_search_result = search_result
return search_result.items
end
--- Get the last search result metadata
--- @return table Search metadata with total_matched and total_files
function M.get_search_metadata()
if not M.state.last_search_result then return { total_matched = 0, total_files = 0 } end
return {
total_matched = M.state.last_search_result.total_matched,
total_files = M.state.last_search_result.total_files,
}
end
--- Get location data from the last search result
--- @return table|nil Location data if available
function M.get_search_location()
if not M.state.last_search_result then return nil end
return M.state.last_search_result.location
end
--- Get score information for a file by index (1-based)
--- @param index number The index of the file in the last search results
--- @return table|nil Score information or nil if not available
function M.get_file_score(index)
if not M.state.last_search_result or not M.state.last_search_result.scores then return nil end
return M.state.last_search_result.scores[index]
end
--- Record file access for frecency tracking
--- @param file_path string Path to the file that was accessed
function M.track_access(file_path)
if not M.state.initialized then return end
local ok, result = pcall(fuzzy.track_access, file_path)
if not ok then vim.notify('Failed to record file access: ' .. tostring(result), vim.log.levels.WARN) end
end
--- Get file content for preview
--- @param file_path string Path to the file
--- @return string|nil File content or nil if failed
function M.get_file_preview(file_path)
local preview = require('fff.file_picker.preview')
-- Create a temporary buffer to get the preview
local temp_buf = vim.api.nvim_create_buf(false, true)
local success = preview.preview(file_path, temp_buf)
if not success then
vim.api.nvim_buf_delete(temp_buf, { force = true })
return nil
end
local lines = vim.api.nvim_buf_get_lines(temp_buf, 0, -1, false)
vim.api.nvim_buf_delete(temp_buf, { force = true })
return table.concat(lines, '\n')
end
--- Check if file picker is initialized
--- @return boolean
function M.is_initialized() return M.state.initialized end
--- Get scan progress information
--- @return table Progress information with scanned_files_count, is_scanning
function M.get_scan_progress()
if not M.state.initialized then return { total_files = 0, scanned_files_count = 0, is_scanning = false } end
local ok, result = pcall(fuzzy.get_scan_progress)
if not ok then
vim.notify('Failed to get scan progress: ' .. tostring(result), vim.log.levels.WARN)
return { scanned_files_count = 0, is_scanning = false }
end
return result
end
--- Refresh git status on cached files (call after git status loading completes)
--- @return table List of files with updated git status
function M.refresh_git_status()
if not M.state.initialized then return {} end
local ok, result = pcall(fuzzy.refresh_git_status)
if not ok then
vim.notify('Failed to refresh git status: ' .. tostring(result), vim.log.levels.WARN)
return {}
end
-- Update our cache
return result
end
--- Stop background git status monitoring
--- @return boolean Success status
function M.stop_background_monitor()
if not M.state.initialized then return false end
local ok, result = pcall(fuzzy.stop_background_monitor)
if not ok then
vim.notify('Failed to stop background monitor: ' .. tostring(result), vim.log.levels.WARN)
return false
end
return result
end
--- Wait for initial scan to complete
--- @param timeout_ms number Optional timeout in milliseconds (default 5000)
--- @return boolean True if scan completed, false if timed out
function M.wait_for_initial_scan(timeout_ms)
if not M.state.initialized then return false end
local ok, result = pcall(fuzzy.wait_for_initial_scan, timeout_ms)
if not ok then
vim.notify('Failed to wait for initial scan: ' .. tostring(result), vim.log.levels.WARN)
return false
end
return result
end
--- Get current state
--- @return table
function M.get_state() return M.state end
return M
+946
View File
@@ -0,0 +1,946 @@
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