Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 461a615878 | |||
| 2951756ae3 | |||
| c17056bcb6 | |||
| abfa5d0ef7 | |||
| d997344fd7 | |||
| e3ba972db6 |
@@ -32,4 +32,4 @@ jobs:
|
||||
- uses: stefanzweifel/git-auto-commit-action@v6
|
||||
with:
|
||||
commit_author: ${{ steps.last-commit.outputs.author }}
|
||||
commit_message: chore: Update docs for - ${{ steps.last-commit.outputs.message }}
|
||||
commit_message: "chore: Update docs for - ${{ steps.last-commit.outputs.message }}"
|
||||
|
||||
@@ -170,6 +170,7 @@ require('fff').setup({
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -207,6 +208,10 @@ require('fff').setup({
|
||||
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
@@ -263,6 +268,59 @@ Select multiple files and send them to Neovim's quickfix list (keymaps are confi
|
||||
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
#### Git Status Highlighting
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators (enabled by default) and optional filename text coloring.
|
||||
|
||||
**Sign Column Indicators** (enabled by default) - Border characters shown in the sign column:
|
||||
```lua
|
||||
hl = {
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
}
|
||||
```
|
||||
|
||||
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
|
||||
|
||||
To enable git status text coloring, set `git.status_text_color = true`:
|
||||
```lua
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true, -- Enable git status colors on filename text
|
||||
},
|
||||
hl = {
|
||||
git_staged = 'FFFGitStaged', -- Files staged for commit
|
||||
git_modified = 'FFFGitModified', -- Modified unstaged files
|
||||
git_deleted = 'FFFGitDeleted', -- Deleted files
|
||||
git_renamed = 'FFFGitRenamed', -- Renamed files
|
||||
git_untracked = 'FFFGitUntracked', -- New untracked files
|
||||
git_ignored = 'FFFGitIgnored', -- Git-ignored files
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
The plugin provides sensible default highlight groups that link to common git highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these with your own custom highlight groups to match your colorscheme.
|
||||
|
||||
**Example - Custom Bright Colors for Text:**
|
||||
```lua
|
||||
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
|
||||
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
|
||||
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true,
|
||||
},
|
||||
hl = {
|
||||
git_modified = 'CustomGitModified',
|
||||
git_untracked = 'CustomGitUntracked',
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
+120
-19
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 October 17
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 December 19
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -86,10 +86,10 @@ VIM.PACK
|
||||
>lua
|
||||
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
|
||||
|
||||
nvim.create_autocmd('PackChanged', {
|
||||
vim.api.nvim_create_autocmd('PackChanged', {
|
||||
callback = function(event)
|
||||
if event.data.updated then
|
||||
require('fff.download').download_or_build_binary()
|
||||
if event.data.updated then
|
||||
require('fff.download').download_or_build_binary()
|
||||
end
|
||||
end,
|
||||
})
|
||||
@@ -128,11 +128,10 @@ all available options:
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
row = nil, -- ratio (0.0 = top edge, 1.0 = bottom edge) nil is centered
|
||||
col = nil, -- ratio (0.0 = left edge, 1.0 = right edge) nil is centered
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -155,11 +154,17 @@ all available options:
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- you can assign multiple keys to any action
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
@@ -171,11 +176,50 @@ all available options:
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
-- Git text highlights for file names
|
||||
git_staged = 'FFFGitStaged',
|
||||
git_modified = 'FFFGitModified',
|
||||
git_deleted = 'FFFGitDeleted',
|
||||
git_renamed = 'FFFGitRenamed',
|
||||
git_untracked = 'FFFGitUntracked',
|
||||
git_ignored = 'FFFGitIgnored',
|
||||
-- Git sign/border highlights
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
-- Git sign selected highlights
|
||||
git_sign_staged_selected = 'FFFGitSignStagedSelected',
|
||||
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
|
||||
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
|
||||
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
|
||||
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
-- Store successfully opened queries with respective matches
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
@@ -217,19 +261,6 @@ FFF.nvim provides several commands for interacting with the file picker:
|
||||
- `:FFFOpenLog` - Open the FFF log file in a new tab
|
||||
|
||||
|
||||
MULTIPLE KEY BINDINGS
|
||||
|
||||
You can assign multiple key combinations to the same action:
|
||||
|
||||
>lua
|
||||
keymaps = {
|
||||
move_up = { '<Up>', '<C-p>', '<C-k>' }, -- Three ways to move up
|
||||
close = { '<Esc>', '<C-c>' }, -- Two ways to close
|
||||
select = '<CR>', -- Single binding still works
|
||||
}
|
||||
<
|
||||
|
||||
|
||||
MULTILINE PASTE SUPPORT
|
||||
|
||||
The input field automatically handles multiline clipboard content by joining
|
||||
@@ -246,6 +277,76 @@ Toggle scoring information display:
|
||||
- Enable by default with `debug.show_scores = true`
|
||||
|
||||
|
||||
MULTI-SELECT AND QUICKFIX INTEGRATION
|
||||
|
||||
Select multiple files and send them to Neovim’s quickfix list (keymaps are
|
||||
configurable):
|
||||
|
||||
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
|
||||
- `<C-q>` - Send selected files to quickfix list and close picker
|
||||
|
||||
|
||||
GIT STATUS HIGHLIGHTING
|
||||
|
||||
FFF integrates with git to show file status through sign column indicators
|
||||
(enabled by default) and optional filename text coloring.
|
||||
|
||||
**Sign Column Indicators** (enabled by default) - Border characters shown in
|
||||
the sign column:
|
||||
|
||||
>lua
|
||||
hl = {
|
||||
git_sign_staged = 'FFFGitSignStaged',
|
||||
git_sign_modified = 'FFFGitSignModified',
|
||||
git_sign_deleted = 'FFFGitSignDeleted',
|
||||
git_sign_renamed = 'FFFGitSignRenamed',
|
||||
git_sign_untracked = 'FFFGitSignUntracked',
|
||||
git_sign_ignored = 'FFFGitSignIgnored',
|
||||
}
|
||||
<
|
||||
|
||||
**Text Highlights** (opt-in) - Apply colors to filenames based on git status:
|
||||
|
||||
To enable git status text coloring, set `git.status_text_color = true`:
|
||||
|
||||
>lua
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true, -- Enable git status colors on filename text
|
||||
},
|
||||
hl = {
|
||||
git_staged = 'FFFGitStaged', -- Files staged for commit
|
||||
git_modified = 'FFFGitModified', -- Modified unstaged files
|
||||
git_deleted = 'FFFGitDeleted', -- Deleted files
|
||||
git_renamed = 'FFFGitRenamed', -- Renamed files
|
||||
git_untracked = 'FFFGitUntracked', -- New untracked files
|
||||
git_ignored = 'FFFGitIgnored', -- Git-ignored files
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
The plugin provides sensible default highlight groups that link to common git
|
||||
highlight groups (e.g., GitSignsAdd, GitSignsChange). You can override these
|
||||
with your own custom highlight groups to match your colorscheme.
|
||||
|
||||
**Example - Custom Bright Colors for Text:**
|
||||
|
||||
>lua
|
||||
vim.api.nvim_set_hl(0, 'CustomGitModified', { fg = '#FFA500' })
|
||||
vim.api.nvim_set_hl(0, 'CustomGitUntracked', { fg = '#00FF00' })
|
||||
|
||||
require('fff').setup({
|
||||
git = {
|
||||
status_text_color = true,
|
||||
},
|
||||
hl = {
|
||||
git_modified = 'CustomGitModified',
|
||||
git_untracked = 'CustomGitUntracked',
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
|
||||
TROUBLESHOOTING ~
|
||||
|
||||
|
||||
|
||||
+44
-17
@@ -10,6 +10,8 @@ local overlay_state = {
|
||||
last_row = nil,
|
||||
last_col = nil,
|
||||
last_border_hl = nil,
|
||||
-- Track if combo was rendered in last call
|
||||
was_rendered = false,
|
||||
}
|
||||
|
||||
local LEFT_OVERLAY_CONTENT = '├────'
|
||||
@@ -104,9 +106,15 @@ local function position_overlay_window(state_key, buf, width, row, col)
|
||||
vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal')
|
||||
end
|
||||
|
||||
local function update_overlays(list_win, combo_header_line, border_hl)
|
||||
local function update_overlays(list_win, combo_header_line, border_hl, prompt_position)
|
||||
local list_config = vim.api.nvim_win_get_config(list_win)
|
||||
-- combo_header_line is a 1-based buffer line index
|
||||
-- list_config.row is the window position (includes border)
|
||||
-- Buffer content starts at row + 1 (after top border)
|
||||
-- For bottom prompt: overlay needs adjustment due to different border handling
|
||||
-- For top prompt: use standard calculation
|
||||
local combo_header_row = list_config.row + combo_header_line
|
||||
if prompt_position == 'bottom' then combo_header_row = combo_header_row - 1 end
|
||||
|
||||
-- Skip update if position and highlight haven't changed
|
||||
if
|
||||
@@ -121,20 +129,16 @@ local function update_overlays(list_win, combo_header_line, border_hl)
|
||||
return
|
||||
end
|
||||
|
||||
-- Cache current values
|
||||
overlay_state.last_row = combo_header_row
|
||||
overlay_state.last_col = list_config.col
|
||||
overlay_state.last_border_hl = border_hl
|
||||
|
||||
-- Update both overlays in a batch to minimize API calls
|
||||
local left_buf = get_or_create_overlay_buf('left_buf')
|
||||
local right_buf = get_or_create_overlay_buf('right_buf')
|
||||
|
||||
-- Update content for both buffers
|
||||
update_overlay_content(left_buf, LEFT_OVERLAY_CONTENT, border_hl)
|
||||
update_overlay_content(right_buf, RIGHT_OVERLAY_CONTENT, border_hl)
|
||||
|
||||
-- Position both windows
|
||||
position_overlay_window('left_win', left_buf, LEFT_OVERLAY_WIDTH, combo_header_row, list_config.col)
|
||||
position_overlay_window(
|
||||
'right_win',
|
||||
@@ -156,10 +160,10 @@ local function clear_overlays_internal()
|
||||
overlay_state.right_win = nil
|
||||
end
|
||||
|
||||
-- Clear cache
|
||||
overlay_state.last_row = nil
|
||||
overlay_state.last_col = nil
|
||||
overlay_state.last_border_hl = nil
|
||||
-- Note: we intentionally don't clear was_rendered here to track the transition
|
||||
end
|
||||
|
||||
function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_multiplier, disable_combo_display)
|
||||
@@ -171,6 +175,8 @@ function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_m
|
||||
return true, header_line, text_len, combo_item_index
|
||||
end
|
||||
|
||||
--- Render combo highlights and overlays
|
||||
--- @return boolean was_hidden True if combo was just hidden (was rendered before, not now)
|
||||
function M.render_highlights_and_overlays(
|
||||
combo_item_index,
|
||||
text_len,
|
||||
@@ -178,26 +184,47 @@ function M.render_highlights_and_overlays(
|
||||
list_win,
|
||||
ns_id,
|
||||
border_hl,
|
||||
item_to_lines
|
||||
item_to_lines,
|
||||
prompt_position
|
||||
)
|
||||
local was_rendered_before = overlay_state.was_rendered
|
||||
local is_rendering_now = false
|
||||
|
||||
if not combo_item_index then
|
||||
clear_overlays_internal()
|
||||
return
|
||||
else
|
||||
local combo_item_lines = item_to_lines[combo_item_index]
|
||||
if not combo_item_lines then
|
||||
clear_overlays_internal()
|
||||
else
|
||||
local combo_header_line_idx = combo_item_lines.first
|
||||
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
|
||||
update_overlays(list_win, combo_header_line_idx, border_hl, prompt_position)
|
||||
is_rendering_now = true
|
||||
end
|
||||
end
|
||||
|
||||
local combo_item_lines = item_to_lines[combo_item_index]
|
||||
if not combo_item_lines then
|
||||
clear_overlays_internal()
|
||||
return
|
||||
end
|
||||
overlay_state.was_rendered = is_rendering_now
|
||||
|
||||
local combo_header_line_idx = combo_item_lines.first
|
||||
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
|
||||
update_overlays(list_win, combo_header_line_idx, border_hl)
|
||||
-- Return true if combo was just hidden (transition from visible to hidden)
|
||||
return was_rendered_before and not is_rendering_now
|
||||
end
|
||||
|
||||
--- Get the combo header text for a given item
|
||||
--- @param combo_count number The combo multiplier count
|
||||
--- @param win_width number Window width for formatting
|
||||
--- @param disable_combo_display boolean Whether to show combo count
|
||||
--- @return string header_text The formatted header line
|
||||
--- @return number text_len Length of the header text (without padding)
|
||||
function M.get_combo_header_text(combo_count, win_width, disable_combo_display)
|
||||
return create_header_text(combo_count, win_width, disable_combo_display)
|
||||
end
|
||||
|
||||
function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end
|
||||
|
||||
function M.cleanup() clear_overlays_internal() end
|
||||
function M.cleanup()
|
||||
clear_overlays_internal()
|
||||
overlay_state.was_rendered = false
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -157,6 +157,7 @@ local function init()
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -192,6 +193,9 @@ local function init()
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
local M = {}
|
||||
local system = require('fff.utils.system')
|
||||
local uv = vim and vim.uv or require('luv')
|
||||
|
||||
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
|
||||
|
||||
@@ -24,7 +23,7 @@ end
|
||||
|
||||
local function binary_exists(plugin_dir)
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
local stat = uv.fs_stat(binary_path)
|
||||
local stat = vim.uv.fs_stat(binary_path)
|
||||
return stat and stat.type == 'file'
|
||||
end
|
||||
|
||||
@@ -32,9 +31,10 @@ local function download_file(url, output_path, opts, callback)
|
||||
opts = opts or {}
|
||||
|
||||
local dir = vim.fn.fnamemodify(output_path, ':h')
|
||||
uv.fs_mkdir(dir, 493, function(err) -- 493 = 0755 octal
|
||||
if err and not err:match('EEXIST') then
|
||||
callback(false, 'Failed to create directory: ' .. err)
|
||||
|
||||
mkdir_recursive(dir, function(mkdir_ok, mkdir_err)
|
||||
if not mkdir_ok then
|
||||
callback(false, mkdir_err)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -89,7 +89,7 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
|
||||
|
||||
if not ok then
|
||||
uv.fs_unlink(binary_path)
|
||||
vim.uv.fs_unlink(binary_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
--- File Renderer
|
||||
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
|
||||
local M = {}
|
||||
|
||||
--- Render Context passed to renderer functions
|
||||
--- @class RenderContext
|
||||
--- @field config table User configuration from conf.get()
|
||||
--- @field items table[] Array of file items being rendered
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
--- @field win_width number Window width in columns
|
||||
--- @field max_path_width number Maximum width for file paths
|
||||
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
|
||||
--- @field prompt_position string Prompt position: 'top' or 'bottom'
|
||||
--- @field has_combo boolean Whether combo boost is active
|
||||
--- @field combo_header_line string Formatted combo header line (if has_combo)
|
||||
--- @field combo_header_text_len number Length of combo header text (if has_combo)
|
||||
--- @field combo_item_index number Index of item with combo (usually 1)
|
||||
--- @field display_start number Start index for displayed items
|
||||
--- @field display_end number End index for displayed items
|
||||
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
|
||||
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
|
||||
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
|
||||
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
|
||||
--- @field selected_files table<string, boolean> Map of selected file paths
|
||||
--- @field query string Current search query
|
||||
--- @field renderer table|nil Custom renderer (if provided via opts)
|
||||
|
||||
--- File Item structure from Rust
|
||||
--- @class FileItem
|
||||
--- @field path string Absolute file path
|
||||
--- @field relative_path string Relative file path from base directory
|
||||
--- @field name string File name
|
||||
--- @field extension string File extension
|
||||
--- @field size number File size in bytes
|
||||
--- @field modified number Last modified timestamp
|
||||
--- @field total_frecency_score number Total frecency score
|
||||
--- @field access_frecency_score number Access-based frecency score
|
||||
--- @field modification_frecency_score number Modification-based frecency score
|
||||
--- @field git_status number|nil Git status enum (if file is in git repo)
|
||||
|
||||
--- Renderer Interface:
|
||||
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
|
||||
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
|
||||
|
||||
--- Render a file item line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @return string[] Array of line strings (1 or 2 lines if combo)
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local lines = {}
|
||||
|
||||
-- Check if this should have combo header (first item with combo boost)
|
||||
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
|
||||
|
||||
if has_combo then table.insert(lines, ctx.combo_header_line) end
|
||||
|
||||
-- Get icon
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
|
||||
-- Build frecency indicator (debug mode only)
|
||||
local frecency = ''
|
||||
if ctx.debug_enabled then
|
||||
local total = item.total_frecency_score or 0
|
||||
local access = item.access_frecency_score or 0
|
||||
local mod = item.modification_frecency_score or 0
|
||||
|
||||
if total > 0 then
|
||||
local indicator = ''
|
||||
if mod >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access >= 4 then
|
||||
indicator = '⭐'
|
||||
elseif total >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total >= 1 then
|
||||
indicator = '•'
|
||||
end
|
||||
frecency = string.format(' %s%d', indicator, total)
|
||||
end
|
||||
end
|
||||
|
||||
-- Format filename and path
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width - #frecency, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- Build line
|
||||
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
|
||||
or string.format('%s %s%s', filename, dir_path, frecency)
|
||||
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
table.insert(lines, line .. string.rep(' ', padding))
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Apply highlights to a rendered line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @param buf number Buffer handle
|
||||
--- @param ns_id number Namespace ID
|
||||
--- @param line_idx number 1-based line index in buffer
|
||||
--- @param line_content string The actual line content
|
||||
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local git_utils = require('fff.git_utils')
|
||||
local file_picker = require('fff.file_picker')
|
||||
|
||||
local is_cursor = (ctx.cursor == item_idx)
|
||||
local score = file_picker.get_file_score(item_idx)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
|
||||
-- Get icon and paths
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- 1. Cursor highlight
|
||||
if is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = line_idx,
|
||||
hl_group = ctx.config.hl.active_file,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
|
||||
-- 2. Icon
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_hl = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
|
||||
end
|
||||
|
||||
-- 3. Git text color (filename)
|
||||
if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then
|
||||
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
|
||||
if git_text_hl and git_text_hl ~= '' and not is_current_file then
|
||||
local filename_start = #icon + 1
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
|
||||
end
|
||||
end
|
||||
|
||||
-- 4. Frecency indicator
|
||||
if ctx.debug_enabled then
|
||||
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
|
||||
if start_pos then
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
|
||||
end
|
||||
end
|
||||
|
||||
-- 5. Directory path (dimmed)
|
||||
if #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #filename + 1 -- filename bytes + space
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.directory_path or 'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
)
|
||||
end
|
||||
|
||||
-- 6. Current file
|
||||
if is_current_file then
|
||||
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
|
||||
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
||||
-- 7. Git sign
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = ctx.config.hl.active_file
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
|
||||
if border_hl and border_hl ~= '' then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
elseif is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = ctx.config.hl.active_file,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
|
||||
-- 8. Selection
|
||||
if ctx.selected_files and ctx.selected_files[item.path] then
|
||||
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001,
|
||||
})
|
||||
end
|
||||
|
||||
-- 9. Query match
|
||||
if ctx.query and ctx.query ~= '' then
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -72,9 +72,9 @@ end
|
||||
--- Get highlight group for git status text
|
||||
--- @param git_status string Git status
|
||||
--- @return string Highlight group name
|
||||
function M.get_highlight(git_status)
|
||||
function M.get_text_highlight(git_status)
|
||||
ensure_cache()
|
||||
return highlights_cache[git_status] or ''
|
||||
return highlights_cache and highlights_cache[git_status] or ''
|
||||
end
|
||||
|
||||
--- Get border highlight group for git status
|
||||
@@ -82,7 +82,7 @@ end
|
||||
--- @return string Highlight group name
|
||||
function M.get_border_highlight(git_status)
|
||||
ensure_cache()
|
||||
return border_highlights_cache[git_status] or ''
|
||||
return border_highlights_cache and border_highlights_cache[git_status] or ''
|
||||
end
|
||||
|
||||
--- Get selected border highlight group for git status
|
||||
@@ -90,7 +90,7 @@ end
|
||||
--- @return string Highlight group name
|
||||
function M.get_border_highlight_selected(git_status)
|
||||
ensure_cache()
|
||||
return border_highlights_selected_cache[git_status] or ''
|
||||
return border_highlights_selected_cache and border_highlights_selected_cache[git_status] or ''
|
||||
end
|
||||
|
||||
function M.get_border_char(git_status) return M.border_chars[git_status] or '' end
|
||||
|
||||
+3
-2
@@ -10,10 +10,11 @@ M.state = { initialized = false }
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- Find files in current directory
|
||||
function M.find_files()
|
||||
--- @param opts? table Optional configuration {renderer = custom_renderer}
|
||||
function M.find_files(opts)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open()
|
||||
picker_ui.open(opts)
|
||||
else
|
||||
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
|
||||
end
|
||||
|
||||
+216
-278
@@ -3,8 +3,6 @@ local M = {}
|
||||
local conf = require('fff.conf')
|
||||
local file_picker = require('fff.file_picker')
|
||||
local preview = require('fff.file_picker.preview')
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local git_utils = require('fff.git_utils')
|
||||
local utils = require('fff.utils')
|
||||
local location_utils = require('fff.location_utils')
|
||||
local combo_renderer = require('fff.combo_renderer')
|
||||
@@ -260,6 +258,10 @@ M.state = {
|
||||
history_offset = nil, -- Current offset in history (nil = not cycling, 0 = first query)
|
||||
next_search_force_combo_boost = false, -- Force combo boost on next search (for history recall)
|
||||
|
||||
-- Combo state
|
||||
combo_visible = true, -- Whether to show combo indicator (hidden after significant navigation)
|
||||
combo_initial_cursor = nil, -- Initial cursor position when combo was shown
|
||||
|
||||
-- Pagination state
|
||||
pagination = {
|
||||
page_index = 0, -- Current page index (0-based)
|
||||
@@ -270,6 +272,9 @@ M.state = {
|
||||
|
||||
config = nil,
|
||||
|
||||
-- Custom renderer (optional, defaults to file_renderer if not provided)
|
||||
renderer = nil,
|
||||
|
||||
ns_id = nil,
|
||||
|
||||
last_status_info = nil,
|
||||
@@ -798,6 +803,10 @@ function M.update_results_sync()
|
||||
M.state.pagination.page_size = page_size
|
||||
M.state.pagination.page_index = 0 -- Reset to first page on new search
|
||||
|
||||
-- Reset combo visibility on new search
|
||||
M.state.combo_visible = true
|
||||
M.state.combo_initial_cursor = 1 -- Will be at position 1 after search
|
||||
|
||||
-- Check if we should force combo boost for this search (history recall)
|
||||
local min_combo_override = nil
|
||||
if M.state.next_search_force_combo_boost then
|
||||
@@ -1015,76 +1024,56 @@ local function format_file_display(item, max_width)
|
||||
return filename, display_path
|
||||
end
|
||||
|
||||
--- Calculate number of rows an item will occupy when rendered
|
||||
--- @param item_index number Index of the item (1-based)
|
||||
--- @param has_combo boolean Whether any combo boost exists
|
||||
--- @param combo_item_index number|nil Index of the combo-boosted item
|
||||
--- @return number Number of rows (1 or 2 currently)
|
||||
local function get_item_row_count(item_index, has_combo, combo_item_index)
|
||||
if has_combo and item_index == combo_item_index then
|
||||
return 2 -- Combo header line + content line
|
||||
end
|
||||
return 1 -- Just content line
|
||||
--- Adjust scroll for bottom prompt to eliminate gaps
|
||||
local function scroll_to_bottom()
|
||||
if not M.state.list_win or not vim.api.nvim_win_is_valid(M.state.list_win) then return end
|
||||
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.list_win)
|
||||
local buf_lines = vim.api.nvim_buf_line_count(M.state.list_buf)
|
||||
|
||||
vim.api.nvim_win_call(M.state.list_win, function()
|
||||
local view = vim.fn.winsaveview()
|
||||
-- Force topline to show content at bottom
|
||||
view.topline = math.max(1, buf_lines - win_height + 1)
|
||||
vim.fn.winrestview(view)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.render_list()
|
||||
if not M.state.active then return end
|
||||
|
||||
--- Build rendering context with all necessary data
|
||||
--- @return table Context object with items, config, dimensions, combo info, etc.
|
||||
local function build_render_context()
|
||||
local config = conf.get()
|
||||
local items = M.state.filtered_items
|
||||
local max_path_width = config.ui and config.ui.max_path_width or 80
|
||||
local debug_enabled = config and config.debug and config.debug.show_scores
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.list_win)
|
||||
local win_width = vim.api.nvim_win_get_width(M.state.list_win)
|
||||
local empty_lines_needed = 0
|
||||
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
|
||||
local has_combo, combo_header_line, combo_header_text_len, combo_item_index = combo_renderer.detect_and_prepare(
|
||||
items,
|
||||
file_picker,
|
||||
win_width,
|
||||
combo_boost_score_multiplier,
|
||||
-- disable rendering of combos if cycling through history or user wants to always show the last match
|
||||
M.state.next_search_force_combo_boost or config.history.min_combo_count == 0
|
||||
)
|
||||
M.state.next_search_force_combo_boost = false -- effectively reset if set by the history recall
|
||||
|
||||
-- Calculate how many items fit (accounting for multi-row items)
|
||||
local display_count = 0
|
||||
local accumulated_rows = 0
|
||||
if #items > 0 then
|
||||
display_count = 1 -- Always show at least first item, even if it exceeds win_height
|
||||
accumulated_rows = get_item_row_count(1, has_combo, combo_item_index)
|
||||
|
||||
for i = 2, #items do
|
||||
local item_rows = get_item_row_count(i, has_combo, combo_item_index)
|
||||
if accumulated_rows + item_rows > win_height then
|
||||
break -- Next item won't fit
|
||||
end
|
||||
accumulated_rows = accumulated_rows + item_rows
|
||||
display_count = i
|
||||
end
|
||||
end
|
||||
|
||||
local prompt_position = get_prompt_position()
|
||||
|
||||
-- All items in M.state.items should be displayed (already paginated)
|
||||
local display_start = 1
|
||||
local display_end = #items
|
||||
|
||||
-- Simple cursor validation
|
||||
-- Cursor validation
|
||||
if M.state.cursor < 1 then
|
||||
M.state.cursor = 1
|
||||
elseif M.state.cursor > #items then
|
||||
M.state.cursor = #items
|
||||
end
|
||||
|
||||
local padded_lines = {}
|
||||
local icon_data = {}
|
||||
local path_data = {}
|
||||
local item_to_lines = {} -- Maps item index to its line indices {first_line, last_line}
|
||||
-- Combo detection
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_multiplier or 100
|
||||
local has_combo, combo_header_line, combo_header_text_len, combo_item_index = combo_renderer.detect_and_prepare(
|
||||
items,
|
||||
file_picker,
|
||||
win_width,
|
||||
combo_boost_score_multiplier,
|
||||
M.state.next_search_force_combo_boost or config.history.min_combo_count == 0
|
||||
)
|
||||
M.state.next_search_force_combo_boost = false
|
||||
|
||||
-- For bottom prompt, iterate in reverse order to render best results at bottom
|
||||
if has_combo and not M.state.combo_visible then
|
||||
has_combo = false
|
||||
combo_item_index = nil
|
||||
end
|
||||
|
||||
-- Determine iteration order
|
||||
local display_start = 1
|
||||
local display_end = #items
|
||||
local iter_start, iter_end, iter_step
|
||||
if prompt_position == 'bottom' then
|
||||
iter_start, iter_end, iter_step = display_end, display_start, -1
|
||||
@@ -1092,265 +1081,187 @@ function M.render_list()
|
||||
iter_start, iter_end, iter_step = display_start, display_end, 1
|
||||
end
|
||||
|
||||
for i = iter_start, iter_end, iter_step do
|
||||
local item = items[i]
|
||||
local item_start_line = #padded_lines + 1
|
||||
return {
|
||||
config = config,
|
||||
items = items,
|
||||
cursor = M.state.cursor,
|
||||
win_height = win_height,
|
||||
win_width = win_width,
|
||||
max_path_width = config.ui and config.ui.max_path_width or 80,
|
||||
debug_enabled = config and config.debug and config.debug.show_scores,
|
||||
prompt_position = prompt_position,
|
||||
has_combo = has_combo,
|
||||
combo_header_line = combo_header_line,
|
||||
combo_header_text_len = combo_header_text_len,
|
||||
combo_item_index = combo_item_index,
|
||||
display_start = display_start,
|
||||
display_end = display_end,
|
||||
iter_start = iter_start,
|
||||
iter_end = iter_end,
|
||||
iter_step = iter_step,
|
||||
renderer = M.state.renderer, -- Custom renderer (if provided)
|
||||
}
|
||||
end
|
||||
|
||||
-- For combo items, insert header first
|
||||
if has_combo and combo_item_index and i == combo_item_index then table.insert(padded_lines, combo_header_line) end
|
||||
--- Generate all display lines
|
||||
--- Uses renderer.render_line for each item
|
||||
--- @param ctx table Render context
|
||||
--- @return table lines Array of padded line strings
|
||||
--- @return table item_to_lines Mapping of item index to {first, last} line indices
|
||||
local function generate_item_lines(ctx)
|
||||
local lines = {}
|
||||
local item_to_lines = {}
|
||||
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
icon_data[i] = { icon, icon_hl_group }
|
||||
-- Add format_file_display to context for renderers
|
||||
ctx.format_file_display = format_file_display
|
||||
|
||||
local frecency = ''
|
||||
if debug_enabled then
|
||||
local total_frecency = (item.total_frecency_score or 0)
|
||||
local access_frecency = (item.access_frecency_score or 0)
|
||||
local mod_frecency = (item.modification_frecency_score or 0)
|
||||
-- Use custom renderer if provided, otherwise use default file_renderer
|
||||
local renderer = ctx.renderer
|
||||
if not renderer then renderer = require('fff.file_renderer') end
|
||||
|
||||
if total_frecency > 0 then
|
||||
local indicator = ''
|
||||
if mod_frecency >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access_frecency >= 4 then
|
||||
indicator = '⭐'
|
||||
elseif total_frecency >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total_frecency >= 1 then
|
||||
indicator = '•'
|
||||
end
|
||||
frecency = string.format(' %s%d', indicator, total_frecency)
|
||||
end
|
||||
for i = ctx.iter_start, ctx.iter_end, ctx.iter_step do
|
||||
local item = ctx.items[i]
|
||||
local item_start_line = #lines + 1
|
||||
|
||||
-- Render item lines using renderer.render_line
|
||||
local item_lines = renderer.render_line(item, ctx, i)
|
||||
|
||||
-- Add rendered lines
|
||||
for _, line in ipairs(item_lines) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(max_path_width - icon_width - #frecency, 40)
|
||||
|
||||
local filename, dir_path = format_file_display(item, available_width)
|
||||
path_data[i] = { filename, dir_path }
|
||||
|
||||
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
|
||||
or string.format('%s %s%s', filename, dir_path, frecency)
|
||||
|
||||
local line_len = vim.fn.strdisplaywidth(line)
|
||||
local padding = math.max(0, win_width - line_len + 5)
|
||||
table.insert(padded_lines, line .. string.rep(' ', padding))
|
||||
|
||||
-- Record line range for this item
|
||||
local item_end_line = #padded_lines
|
||||
local item_end_line = #lines
|
||||
item_to_lines[i] = {
|
||||
first = item_start_line,
|
||||
last = item_end_line,
|
||||
}
|
||||
end
|
||||
|
||||
-- Handle bottom positioning: add empty lines at the top
|
||||
local empty_line_offset = 0
|
||||
if prompt_position == 'bottom' then
|
||||
local total_content_lines = #padded_lines
|
||||
empty_lines_needed = math.max(0, win_height - total_content_lines)
|
||||
return lines, item_to_lines
|
||||
end
|
||||
|
||||
if empty_lines_needed > 0 then
|
||||
-- Insert empty lines at the beginning
|
||||
for i = empty_lines_needed, 1, -1 do
|
||||
table.insert(padded_lines, 1, string.rep(' ', win_width + 5))
|
||||
end
|
||||
empty_line_offset = empty_lines_needed
|
||||
--- Apply bottom padding for bottom prompt position
|
||||
--- Adds empty lines at the top and adjusts all line indices
|
||||
--- @param lines table Array of line strings (mutated in place)
|
||||
--- @param item_to_lines table Item to lines mapping (mutated in place)
|
||||
--- @param ctx table Render context
|
||||
local function apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
if ctx.prompt_position ~= 'bottom' then return end
|
||||
|
||||
-- Adjust item_to_lines mapping
|
||||
for i = display_start, display_end do
|
||||
if item_to_lines[i] then
|
||||
item_to_lines[i].first = item_to_lines[i].first + empty_line_offset
|
||||
item_to_lines[i].last = item_to_lines[i].last + empty_line_offset
|
||||
end
|
||||
local total_content_lines = #lines
|
||||
local empty_lines_needed = math.max(0, ctx.win_height - total_content_lines)
|
||||
|
||||
if empty_lines_needed > 0 then
|
||||
-- Insert empty lines at the beginning
|
||||
for i = empty_lines_needed, 1, -1 do
|
||||
table.insert(lines, 1, string.rep(' ', ctx.win_width + 5))
|
||||
end
|
||||
|
||||
-- Adjust item_to_lines mapping
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
if item_to_lines[i] then
|
||||
item_to_lines[i].first = item_to_lines[i].first + empty_lines_needed
|
||||
item_to_lines[i].last = item_to_lines[i].last + empty_lines_needed
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate cursor line based on current item
|
||||
--- Update buffer content and position cursor
|
||||
--- @param lines table Array of line strings
|
||||
--- @param item_to_lines table Item to lines mapping
|
||||
--- @param ctx table Render context
|
||||
local function update_buffer_and_cursor(lines, item_to_lines, ctx)
|
||||
-- Calculate cursor line position
|
||||
local cursor_line = 0
|
||||
if #items > 0 and M.state.cursor >= 1 and M.state.cursor <= #items then
|
||||
local cursor_item = item_to_lines[M.state.cursor]
|
||||
if #ctx.items > 0 and ctx.cursor >= 1 and ctx.cursor <= #ctx.items then
|
||||
local cursor_item = item_to_lines[ctx.cursor]
|
||||
if cursor_item then cursor_line = cursor_item.last end
|
||||
end
|
||||
|
||||
-- Update buffer
|
||||
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_lines(M.state.list_buf, 0, -1, false, padded_lines)
|
||||
vim.api.nvim_buf_set_lines(M.state.list_buf, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', false)
|
||||
|
||||
-- Clear existing highlights
|
||||
vim.api.nvim_buf_clear_namespace(M.state.list_buf, M.state.ns_id, 0, -1)
|
||||
|
||||
-- Set cursor position
|
||||
if #items > 0 and cursor_line > 0 and cursor_line <= win_height then
|
||||
-- Position cursor
|
||||
if #ctx.items > 0 and cursor_line > 0 and cursor_line <= #lines then
|
||||
vim.api.nvim_win_set_cursor(M.state.list_win, { cursor_line, 0 })
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply highlighting to all items
|
||||
if #items > 0 then
|
||||
for i = display_start, display_end do
|
||||
local item = items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if item_lines then
|
||||
local is_cursor_item = (M.state.cursor == i)
|
||||
--- Apply all highlights using renderer.apply_highlights
|
||||
--- @param lines table Array of line strings
|
||||
--- @param item_to_lines table Item to lines mapping
|
||||
--- @param ctx table Render context
|
||||
local function apply_all_highlights(lines, item_to_lines, ctx)
|
||||
ctx.selected_files = M.state.selected_files
|
||||
ctx.query = M.state.query
|
||||
|
||||
-- Highlight only the content line (last line), not the combo header
|
||||
if is_cursor_item then
|
||||
local content_line = item_lines.last
|
||||
-- Highlight entire line and extend to EOL
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, content_line - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = content_line,
|
||||
hl_group = M.state.config.hl.active_file,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
local renderer = ctx.renderer
|
||||
if not renderer then renderer = require('fff.file_renderer') end
|
||||
|
||||
-- Now apply file-specific highlights to the last line
|
||||
local line_idx = item_lines.last
|
||||
local line_content = padded_lines[line_idx]
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
local item = ctx.items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if not item_lines then goto continue end
|
||||
|
||||
if line_content then
|
||||
local icon, icon_hl_group = unpack(icon_data[i])
|
||||
local filename, dir_path = unpack(path_data[i])
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
|
||||
local score = file_picker.get_file_score(i)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
if not line_content then goto continue end
|
||||
|
||||
-- Icon highlighting
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_highlight = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
icon_highlight,
|
||||
line_idx - 1,
|
||||
0,
|
||||
vim.fn.strdisplaywidth(icon)
|
||||
)
|
||||
end
|
||||
-- Apply highlights using renderer.apply_highlights
|
||||
renderer.apply_highlights(item, ctx, i, M.state.list_buf, M.state.ns_id, line_idx, line_content)
|
||||
::continue::
|
||||
end
|
||||
end
|
||||
|
||||
-- Frecency highlighting
|
||||
if debug_enabled then
|
||||
local star_start, star_end = line_content:find('⭐%d+')
|
||||
if star_start then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
M.state.config.hl.frecency,
|
||||
line_idx - 1,
|
||||
star_start - 1,
|
||||
star_end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local icon_match = line_content:match('^%S+')
|
||||
if icon_match and #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #icon_match + 1 + #filename + 1
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
)
|
||||
end
|
||||
|
||||
if is_current_file then
|
||||
if not is_cursor_item then
|
||||
vim.api.nvim_buf_add_highlight(M.state.list_buf, M.state.ns_id, 'Comment', line_idx - 1, 0, -1)
|
||||
end
|
||||
|
||||
local virt_text_hl = is_cursor_item and M.state.config.hl.active_file or 'Comment'
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
|
||||
if is_cursor_item then
|
||||
-- When selected, create a combined highlight: border color on cursor background
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
-- Get the foreground color from the border highlight
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
-- Get the background from cursor highlight
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(M.state.config.hl.active_file)), 'bg')
|
||||
|
||||
-- Create temporary highlight group
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. i
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = M.state.config.hl.active_file
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
|
||||
if border_hl and border_hl ~= '' then
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
elseif is_cursor_item then
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = M.state.config.hl.active_file,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
|
||||
if M.state.selected_files[item.path] then
|
||||
local selection_hl = is_cursor_item and M.state.config.hl.selected_active or M.state.config.hl.selected
|
||||
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001, -- Higher than git status (1000)
|
||||
})
|
||||
end
|
||||
|
||||
local match_start, match_end = string.find(line_content, M.state.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
combo_renderer.render_highlights_and_overlays(
|
||||
combo_item_index,
|
||||
combo_header_text_len,
|
||||
M.state.list_buf,
|
||||
M.state.list_win,
|
||||
M.state.ns_id,
|
||||
M.state.config.hl.border,
|
||||
item_to_lines
|
||||
)
|
||||
-- Renders all virtual buffer overalys
|
||||
local function finalize_render(item_to_lines, ctx)
|
||||
-- Get text_len from item_to_lines if combo exists
|
||||
local combo_text_len = nil
|
||||
if ctx.combo_item_index and item_to_lines[ctx.combo_item_index] then
|
||||
combo_text_len = item_to_lines[ctx.combo_item_index].combo_header_text_len
|
||||
end
|
||||
|
||||
-- Render scrollbar (will be created lazily if needed)
|
||||
scrollbar.render(M.state.layout, M.state.config, M.state.list_win, M.state.pagination)
|
||||
-- Render combo overlays
|
||||
local combo_was_hidden = combo_renderer.render_highlights_and_overlays(
|
||||
ctx.combo_item_index,
|
||||
combo_text_len or ctx.combo_header_text_len,
|
||||
M.state.list_buf,
|
||||
M.state.list_win,
|
||||
M.state.ns_id,
|
||||
ctx.config.hl.border,
|
||||
item_to_lines,
|
||||
ctx.prompt_position
|
||||
)
|
||||
|
||||
-- Handle combo hiding with scroll adjustment
|
||||
if combo_was_hidden and ctx.prompt_position == 'bottom' then scroll_to_bottom() end
|
||||
|
||||
-- Render scrollbar
|
||||
scrollbar.render(M.state.layout, ctx.config, M.state.list_win, M.state.pagination, ctx.prompt_position)
|
||||
end
|
||||
|
||||
function M.render_list()
|
||||
if not M.state.active then return end
|
||||
|
||||
local ctx = build_render_context()
|
||||
local lines, item_to_lines = generate_item_lines(ctx)
|
||||
|
||||
apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
update_buffer_and_cursor(lines, item_to_lines, ctx)
|
||||
|
||||
if #ctx.items > 0 then apply_all_highlights(lines, item_to_lines, ctx) end
|
||||
|
||||
-- 6. Finalize with combo overlays and scrollbar
|
||||
finalize_render(item_to_lines, ctx)
|
||||
end
|
||||
|
||||
function M.update_preview()
|
||||
@@ -1553,6 +1464,18 @@ function M.move_up()
|
||||
M.render_list()
|
||||
M.update_preview()
|
||||
M.update_status()
|
||||
|
||||
if M.state.combo_initial_cursor and M.state.combo_visible then
|
||||
local cursor_distance = math.abs(M.state.cursor - M.state.combo_initial_cursor)
|
||||
local half_page = math.floor(M.state.pagination.page_size / 2)
|
||||
if cursor_distance > half_page then
|
||||
M.state.combo_visible = false
|
||||
combo_renderer.cleanup()
|
||||
M.render_list() -- Re-render once without combo
|
||||
-- Scroll to bottom for bottom prompt to eliminate gap
|
||||
if get_prompt_position() == 'bottom' then scroll_to_bottom() end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.move_down()
|
||||
@@ -1597,6 +1520,18 @@ function M.move_down()
|
||||
M.render_list()
|
||||
M.update_preview()
|
||||
M.update_status()
|
||||
|
||||
if M.state.combo_initial_cursor and M.state.combo_visible then
|
||||
local cursor_distance = math.abs(M.state.cursor - M.state.combo_initial_cursor)
|
||||
local half_page = math.floor(M.state.pagination.page_size / 2)
|
||||
if cursor_distance > half_page then
|
||||
M.state.combo_visible = false
|
||||
combo_renderer.cleanup()
|
||||
M.render_list() -- Re-render once without combo
|
||||
-- Scroll to bottom for bottom prompt to eliminate gap
|
||||
if get_prompt_position() == 'bottom' then scroll_to_bottom() end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Scroll preview up by half window height
|
||||
@@ -1901,6 +1836,8 @@ function M.close()
|
||||
M.state.current_file_cache = nil
|
||||
M.state.location = nil
|
||||
M.state.selected_files = {}
|
||||
M.state.combo_visible = true
|
||||
M.state.combo_initial_cursor = nil
|
||||
M.reset_history_state()
|
||||
-- Clean up picker focus autocmds
|
||||
pcall(vim.api.nvim_del_augroup_by_name, 'fff_picker_focus')
|
||||
@@ -2051,11 +1988,12 @@ end
|
||||
--- @param opts.layout.prompt_position? string|function Prompt position: 'top'|'bottom' or function(terminal_width, terminal_height): string (default: 'bottom')
|
||||
--- @param opts.layout.preview_position? string|function Preview position: 'left'|'right'|'top'|'bottom' or function(terminal_width, terminal_height): string (default: 'right')
|
||||
--- @param opts.layout.preview_size? number|function Preview size as ratio (0.0-1.0) or function(terminal_width, terminal_height): number (default: 0.5)
|
||||
--- @param opts.renderer? table Custom renderer implementing {render_line, apply_highlights} interface (default: file_renderer)
|
||||
function M.open(opts)
|
||||
if M.state.active then return end
|
||||
|
||||
-- Initialize selection state
|
||||
M.state.selected_files = {}
|
||||
M.state.renderer = opts and opts.renderer or nil
|
||||
|
||||
local merged_config, base_path = initialize_picker(opts)
|
||||
if not merged_config then return end
|
||||
|
||||
@@ -37,6 +37,7 @@ local function try_load_library()
|
||||
local stat = vim.uv.fs_stat(actual_path)
|
||||
if stat and stat.type == 'file' then
|
||||
local loader, err = package.loadlib(actual_path, 'luaopen_fff_nvim')
|
||||
if err then return nil, string.format('Error loading library from %s: %s', actual_path, err) end
|
||||
if loader then return loader() end
|
||||
end
|
||||
end
|
||||
@@ -44,12 +45,13 @@ local function try_load_library()
|
||||
end
|
||||
|
||||
local backend, load_err = try_load_library()
|
||||
if not backend then
|
||||
if not backend or load_err then
|
||||
local err_msg = string.format(
|
||||
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists with `cargo build --release`',
|
||||
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists or make it exists using \n `:lua require("fff.download").download_or_build_binary()`\nor\n`cargo build --release`\n(and rerun neovim after)',
|
||||
tostring(load_err),
|
||||
vim.inspect(paths)
|
||||
)
|
||||
|
||||
error(err_msg)
|
||||
end
|
||||
|
||||
|
||||
+13
-2
@@ -16,12 +16,15 @@ local ns_id = vim.api.nvim_create_namespace('fff_scrollbar')
|
||||
--- @param config table Config with hl (highlight groups)
|
||||
--- @param list_win number List window handle
|
||||
--- @param pagination table Pagination state with page_index, page_size, total_matched
|
||||
function M.render(layout, config, list_win, pagination)
|
||||
--- @param prompt_position string|nil Prompt position ('top' or 'bottom', defaults to 'bottom')
|
||||
function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
if layout.show_scrollbar == false then return end
|
||||
|
||||
-- this is the most often path, we don't want to show scrollbar if use doesn't scrolling
|
||||
if not scrollbar_state.ever_shown and pagination.page_index == 0 then return end
|
||||
|
||||
prompt_position = prompt_position or 'bottom'
|
||||
|
||||
local total_pages = pagination.page_size > 0 and math.ceil(pagination.total_matched / pagination.page_size) or 1
|
||||
local has_multiple_pages = total_pages > 1
|
||||
local scrollbar_exists = scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win)
|
||||
@@ -61,7 +64,15 @@ function M.render(layout, config, list_win, pagination)
|
||||
|
||||
local thumb_size = math.max(1, math.floor(win_height / total_pages))
|
||||
local scrollbar_range = win_height - thumb_size
|
||||
local thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
|
||||
|
||||
-- inverse the scrollbar when the position is at the bottom
|
||||
local thumb_start
|
||||
if prompt_position == 'bottom' then
|
||||
thumb_start =
|
||||
math.floor(((total_pages - 1 - pagination.page_index) / math.max(1, total_pages - 1)) * scrollbar_range)
|
||||
else
|
||||
thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
|
||||
end
|
||||
|
||||
local lines = {}
|
||||
for i = 1, win_height do
|
||||
|
||||
@@ -59,4 +59,35 @@ function M.is_one_of(value, values)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Recursively create directories using uv.fs_mkdir
|
||||
local function mkdir_recursive(path, callback)
|
||||
vim.uv.fs_stat(path, function(err, stat)
|
||||
if not err and stat then
|
||||
callback(true, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local parent = vim.fn.fnamemodify(path, ':h')
|
||||
if parent == path or parent == '' or parent == '.' then
|
||||
callback(false, 'Cannot create root directory')
|
||||
return
|
||||
end
|
||||
|
||||
mkdir_recursive(parent, function(parent_ok, parent_err)
|
||||
if not parent_ok then
|
||||
callback(false, parent_err)
|
||||
return
|
||||
end
|
||||
|
||||
uv.fs_mkdir(path, 493, function(mkdir_err) -- 493 = 0755 octal
|
||||
if mkdir_err and not mkdir_err:match('EEXIST') then
|
||||
callback(false, 'Failed to create directory: ' .. mkdir_err)
|
||||
return
|
||||
end
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
Reference in New Issue
Block a user