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
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user