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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:16 +08:00
commit d5f207424b
328 changed files with 87463 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# `fff.picker_ui` — Picker UI Module
This directory contains the full implementation of the FFF file picker UI.
Originally a single monolithic ~2535-line file, it has been split into focused submodules.
## Entry point
**`picker_ui.lua`** — requires `require('fff.picker_ui.picker_ui')`.
This is the coordinator. It wires all submodules together and exposes the public API:
| Function | Purpose |
|---|---|
| `M.open(opts)` | Open the file picker |
| `M.open_with_callback(query, callback, opts)` | Search with a callback, fall back to opening the picker |
| `M.select(action)` | Open the selected file (edit/split/vsplit/tab) |
| `M.toggle_select()` | Toggle multi-selection for the current item |
| `M.send_to_quickfix()` | Send selected items to the quickfix list |
| `M.toggle_debug()` | Toggle debug score display |
| `M.monitor_scan_progress()` | Poll indexing progress during initial scan |
## Submodules
| Module | File | Purpose | Key exports via `picker_ui.lua` |
|---|---|---|---|
| **picker_ui_state** | `picker_ui_state.lua` | Single source of truth for all picker state | `M.state`, `M.clear_selections`, `M.reset_history_state` |
| **ui_creator** | `ui_creator.lua` | Creates buffers, windows, and keymaps for the picker UI | `M.create_ui`, `M.setup_buffers`, `M.setup_windows`, `M.setup_keymaps`, `M.focus_*_win`, `M.open_preview`, `M.close_preview` |
| **search_manager** | `search_manager.lua` | Executes searches, manages pagination, handles query history | `M.update_results_sync`, `M.update_results`, `M.load_*_page`, `M.on_input_change`, `M.cycle_grep_modes`, `M.recall_query_from_history`, `M.cycle_forward_query`, `M.get_suggestion_renderer` |
| **renderer** | `renderer.lua` | Renders the file list, handles combo separator, scrollbar, and empty state | `M.render_list`, `M.scroll_to_bottom` |
| **preview_manager** | `preview_manager.lua` | Manages file preview rendering, debounced updates, and preview title | `M.update_preview`, `M.update_preview_smart`, `M.update_preview_debounced`, `M.update_preview_title`, `M.clear_preview` |
| **navigation** | `navigation.lua` | Handles cursor movement, pagination wrap-around, and preview scrolling | `M.move_up`, `M.move_down`, `M.wrap_to_first`, `M.wrap_to_last`, `M.scroll_preview_up`, `M.scroll_preview_down` |
| **layout_manager** | `layout_manager.lua` | Recalculates layout on terminal resize (VimResized) and cleans up on close | `M.relayout`, `M.close` |
| **file_renderer** | `file_renderer.lua` | Renders individual file lines with icon, path, and score | `M.render_line`, `M.apply_highlights` |
| **grep_renderer** | `grep_renderer.lua` | Grep search execution and rendering of grep result lines | `M.search`, `M.get_search_metadata`, `M.render_line`, `M.apply_highlights` |
| **combo_renderer** | `combo_renderer.lua` | Detects combo (directory common prefix) items from a list | `M.detect` |
| **list_renderer** | `list_renderer.lua` | Renders the full file list with separator, scrollbar, and combo display | `M.render` |
| **utils** | `utils.lua` | Utility functions (quickfix list building) | `M.send_to_quickfix` |
### Module relationships
```
picker_ui.lua (coordinator)
├── ui_creator.lua — buffer/window/keymap creation
├── search_manager.lua → grep_renderer.lua → file_renderer.lua
├── renderer.lua → list_renderer.lua, combo_renderer.lua
│ ├─ file_renderer.lua
│ └─ list_separator (external)
├── preview_manager.lua
├── navigation.lua
├── layout_manager.lua
└── utils.lua → grep_renderer.lua (for exhaustive grep in send_to_quickfix)
```
## Architecture
All submodules follow the same pattern:
```lua
local M = {}
local P = nil -- parent module reference
function M.init(parent_module) P = parent_module end
local S = picker_ui_state.state -- shared state
-- ... functions that use S.* and P.* ...
return M
```
The coordinator (`picker_ui.lua`) calls `module.init(M)` on each submodule, passing itself as the parent. This lets submodules call back into `picker_ui.lua` for cross-module coordination — for example, `navigation.lua` calls `P.render_list()` and `P.update_preview()` after moving the cursor.
State is shared via a single table reference (`picker_ui_state.state`). Every submodule writes to and reads from the same table — no message passing or event bus.
Modules without `init()` are either pure data stores (`picker_ui_state`) or standalone utility modules without cross-module dependencies (`file_renderer`, `grep_renderer`, `combo_renderer`, `list_renderer`, `utils`).
## Key design decisions
- **`picker_ui_state` is a pure data store** — no parent module reference, no `init()`. It owns the state table and selection helpers.
- **`picker_ui.lua` keeps cross-cutting concerns** — `update_status`, `select`, `toggle_debug`, `open` lives here because they coordinate across multiple submodules.
- **Module purpose is reflected in the filename** — anything with `_manager` in the name manages state or lifecycle; `renderer` and `navigation` are purely behavioral; `*_renderer` modules handle rendering of specific item types.
- **`vim.schedule` usage is intentional** — deferred calls prevent re-entrancy issues during buffer mutations and window teardown.
+49
View File
@@ -0,0 +1,49 @@
--- Combo header policy.
---
--- Pure logic: detects whether the current result set has a "combo" boost,
--- and produces the label text for the list separator. The actual divider
--- is rendered by `list_separator` so this module owns no windows or buffers.
local M = {}
local COMBO_TEXT_FORMAT = 'Last Match (×%d combo)'
local LAST_MATCH_TEXT = 'Last Match'
--- @param items table[]
--- @param file_picker table
--- @param combo_boost_score_multiplier number
--- @return number|nil idx 1-based item index of the combo anchor, nil if none
--- @return number combo_count Multiplier (combo_match_boost / multiplier)
local function detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not items or #items == 0 then return nil, 0 end
local first_score = file_picker.get_file_score(1)
local last_score = file_picker.get_file_score(#items)
if first_score and first_score.combo_match_boost > combo_boost_score_multiplier then
return 1, first_score.combo_match_boost / combo_boost_score_multiplier
elseif last_score and last_score.combo_match_boost > combo_boost_score_multiplier then
return #items, last_score.combo_match_boost / combo_boost_score_multiplier
end
return nil, 0
end
--- @class FffComboInfo
--- @field idx number Item index of the combo anchor
--- @field text string Label text for the separator
--- @field count number Combo multiplier
--- @param items table[]
--- @param file_picker table
--- @param combo_boost_score_multiplier number
--- @param disable_combo_display boolean
--- @return FffComboInfo|nil
function M.detect(items, file_picker, combo_boost_score_multiplier, disable_combo_display)
local idx, count = detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not idx then return nil end
local text = disable_combo_display and LAST_MATCH_TEXT or string.format(COMBO_TEXT_FORMAT, count)
return { idx = idx, text = text, count = count }
end
return M
+217
View File
@@ -0,0 +1,217 @@
--- File Renderer
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
local M = {}
--- 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 string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo
--- Render a file item line
--- @param item FileItem File item from Rust
--- @param ctx ListRenderContext Render context with all state
--- @param item_idx number|nil 1-based item index in ctx.items
--- @return string[] Array of line strings (always exactly 1)
function M.render_line(item, ctx, item_idx) -- luacheck: ignore item_idx
local icons = require('fff.file_picker.icons')
local lines = {}
local icon, _ = 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
-- Don't reserve space for frecency - path takes priority
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)
-- 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 ListRenderContext 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 highlights = require('fff.highlights')
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.cursor,
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_set_extmark(
buf,
ns_id,
line_idx - 1,
0,
{ end_col = vim.fn.strdisplaywidth(icon), hl_group = icon_hl }
)
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 highlights.get_git_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_set_extmark(
buf,
ns_id,
line_idx - 1,
filename_start,
{ end_col = filename_start + #filename, hl_group = git_text_hl }
)
end
end
-- 4. Frecency indicator
if ctx.debug_enabled then
local start_pos, end_pos = line_content:find('[⭐️🔥✨•]%d+')
if start_pos and end_pos then
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
start_pos - 1,
{ end_col = end_pos, hl_group = ctx.config.hl.frecency }
)
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_set_extmark(
buf,
ns_id,
line_idx - 1,
prefix_len,
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
)
end
-- 6. Current file
if is_current_file then
local hl
if is_cursor then
hl = ctx.config.hl.cursor
else
hl = 'Comment'
end
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
virt_text = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
virt_text_pos = 'right_align',
})
end
-- 7. Git sign
if item.git_status and highlights.should_show_git_border(item.git_status) then
local border_char = highlights.get_git_border_char(item.git_status)
local border_hl = highlights.get_git_sign_highlight(item.git_status, is_cursor, ctx.config.hl.cursor)
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.cursor,
priority = 1000,
})
end
-- 8. Selection
if ctx.selected_files and ctx.selected_files[item.relative_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, true)
if match_start and match_end then
vim.api.nvim_buf_set_extmark(
buf,
ns_id,
line_idx - 1,
match_start - 1,
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
)
end
end
end
return M
+297
View File
@@ -0,0 +1,297 @@
--- Grep search bridge and renderer.
--- Wraps the Rust `live_grep` FFI function with file-based pagination state tracking.
--- Also provides renderer for live grep results with file grouping.
local M = {
supports_cursor_rerender = true,
}
local fuzzy = require('fff.fuzzy')
local file_renderer = require('fff.picker_ui.file_renderer')
local tresitter_highlight = require('fff.treesitter_hl')
-- ===== Search Bridge =====
---@class fff.grep.SearchResult
---@field items table[] Array of grep match items
---@field total_matched number Total matches found in this call
---@field total_files_searched number Files actually searched in this call
---@field total_files number Total indexed files
---@field filtered_file_count number Total searchable files after filtering
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
---@field regex_fallback_error string|nil Error message if regex compilation failed and search fell back to literal
local last_result = nil
--- Perform a grep search.
---@param query string The search query (may contain file constraints like *.rs)
---@param file_offset? number Index into sorted file list to start from (default 0)
---@param page_size? number Max matches to collect (default 50)
---@param config? table Grep configuration overrides
---@param grep_mode? string Search mode: "plain" (default), "regex", or "fuzzy"
---@return fff.grep.SearchResult
function M.search(query, file_offset, page_size, config, grep_mode)
local conf = config or {}
last_result = fuzzy.live_grep(
query or '',
file_offset or 0,
page_size or 50,
conf.max_file_size,
conf.max_matches_per_file,
conf.smart_case,
grep_mode or 'plain',
conf.time_budget_ms,
conf.trim_whitespace
)
return last_result
end
--- Get metadata from the last search result.
---@return { total_matched: number, total_files_searched: number, total_files: number, next_file_offset: number }
function M.get_search_metadata()
if not last_result then
return { total_matched = 0, total_files_searched = 0, total_files = 0, next_file_offset = 0 }
end
return {
total_matched = last_result.total_matched or 0,
total_files_searched = last_result.total_files_searched or 0,
total_files = last_result.total_files or 0,
next_file_offset = last_result.next_file_offset or 0,
}
end
-- ===== Renderer =====
--- Build the file group header line using the same layout as file_renderer.
--- Delegates to file_renderer.render_line (with combo disabled).
---@param item FileItem Grep match
---@param ctx table Render context
---@return string The header line string
local function build_group_header(item, ctx)
local lines = file_renderer.render_line(item, ctx)
return lines[1]
end
--- Apply highlights for a file group header line using file_renderer.
---@param item FileItem Grep match item
---@param ctx ListRenderContext Render context
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param row number 0-based row in buffer (header line)
local function apply_group_header_highlights(item, ctx, buf, ns_id, row)
local line_content = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1] or ''
local saved_cursor = ctx.cursor
ctx.cursor = -1
file_renderer.apply_highlights(item, ctx, 0, buf, ns_id, row + 1, line_content)
ctx.cursor = saved_cursor
end
--- Format a grep match location string.
---@param item table Grep match item
---@param ctx table Render context
---@return string
local function format_location(item, ctx)
local fmt = (ctx.config and ctx.config.grep and ctx.config.grep.location_format) or ':%d:%d'
local ok, str = pcall(string.format, fmt, item.line_number or 0, (item.col or 0) + 1)
if not ok then str = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1) end
return str
end
local BINARY_PLACEHOLDER = '<binary content>'
--- Render a single grep match line.
---@param item table Grep match item
---@param ctx table Render context
---@return string
local function render_match_line(item, ctx)
local location = format_location(item, ctx)
local separator = ' '
local raw_content = item.line_content
if type(raw_content) ~= 'string' then raw_content = raw_content and tostring(raw_content) or '' end
local content = raw_content
if item.is_binary_content then content = BINARY_PLACEHOLDER end
local indent = ' '
local prefix_display_w = #indent + #location + #separator
local available = ctx.win_width - prefix_display_w - 2
local content_display_w = vim.fn.strdisplaywidth(content)
if content_display_w > available and available > 3 then
local nchars = vim.fn.strchars(content)
local lo, hi = 0, nchars
while lo < hi do
local mid = math.floor((lo + hi + 1) / 2)
if vim.fn.strdisplaywidth(vim.fn.strcharpart(content, 0, mid)) <= available - 1 then
lo = mid
else
hi = mid - 1
end
end
content = vim.fn.strcharpart(content, 0, lo) .. ''
end
local line = indent .. location .. separator .. content
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
item._match_indent = #indent
item._content_offset = prefix_display_w
item._trimmed_content = content
return line .. string.rep(' ', padding)
end
--- Apply highlights for a grouped match line.
---@param item table Grep match item
---@param item_idx number 1-based item index
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param row number 0-based row in buffer
---@param line_content string The rendered line text
local function apply_match_highlights(item, item_idx, buf, ns_id, row, line_content, ctx)
local config = ctx.config
local is_cursor = item_idx == ctx.cursor
local indent = item._match_indent or 1
if is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
end_col = 0,
end_row = row + 1,
hl_group = config.hl.cursor,
hl_eol = true,
priority = 100,
})
end
local location_str = format_location(item, ctx)
local loc_start = indent
local loc_end = loc_start + #location_str
if loc_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, loc_start, {
end_col = loc_end,
hl_group = config.hl.grep_line_number or 'LineNr',
priority = 150,
})
end
local sep_start = loc_end
local sep_end = sep_start + 2
if sep_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, sep_start, {
end_col = sep_end,
hl_group = 'Comment',
priority = 150,
})
end
local content_start = sep_end
if item.is_binary_content then
local content_end = content_start + #BINARY_PLACEHOLDER
if content_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, content_start, {
end_col = content_end,
hl_group = 'Comment',
priority = 150,
})
end
elseif item._trimmed_content and item.name then
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
local lang = ctx._ts_lang_cache[item.name]
if lang == nil then
lang = tresitter_highlight.lang_from_filename(item.name) or false
ctx._ts_lang_cache[item.name] = lang
end
if lang then
local highlights = tresitter_highlight.get_line_highlights(item._trimmed_content, lang)
for _, hl in ipairs(highlights) do
local hl_start = content_start + hl.col
local hl_end = content_start + hl.end_col
if hl_start < #line_content and hl_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
end_col = hl_end,
hl_group = hl.hl_group,
priority = 120,
})
end
end
end
end
if item.match_ranges and not item.is_binary_content then
for _, range in ipairs(item.match_ranges) do
local raw_start = range[1] or 0
local raw_end = range[2] or 0
if raw_end > 0 then
raw_start = math.max(0, raw_start)
local hl_start = content_start + raw_start
local hl_end = content_start + raw_end
if hl_start < #line_content and hl_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
end_col = hl_end,
hl_group = config.hl.grep_match or 'IncSearch',
priority = 200,
})
end
end
end
end
if ctx.selected_items then
local key = string.format('%s:%d:%d', item.relative_path, item.line_number or 0, item.col or 0)
if ctx.selected_items[key] then
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
sign_text = '',
sign_hl_group = config.hl.selected or 'FFFSelected',
priority = 1001,
})
end
end
end
--- Render a single item's lines (called by list_renderer).
--- Returns 2 lines [header, match] for the first match of a file group,
--- or 1 line [match] for subsequent matches in the same file.
---@param item FileItem Grep match item
---@param ctx table Render context
---@param item_idx number 1-based item index in ctx.items
---@return string[]
function M.render_line(item, ctx, item_idx)
-- First rendered item in this pass always gets header — fixes missing header
-- when paginating backward in multi-page grep results (ctx is fresh per render).
local is_first_visible = (item_idx == ctx.iter_start)
local is_new_group = is_first_visible or (item.relative_path ~= ctx.grep_last_file)
ctx.grep_last_file = item.relative_path
local match_line = render_match_line(item, ctx)
if is_new_group then
---@diagnostic disable-next-line: inject-field
item._has_group_header = true
local header_line = build_group_header(item, ctx)
return { header_line, match_line }
else
---@diagnostic disable-next-line: inject-field
item._has_group_header = false
return { match_line }
end
end
--- Apply highlights for rendered lines (called by list_renderer).
---@param item FileItem Grep match item
---@param ctx ListRenderContext Render context
---@param item_idx number 1-based item index
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param line_idx number 1-based line index of the match line
---@param line_content string The rendered match line text
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
local row = line_idx - 1
apply_match_highlights(item, item_idx, buf, ns_id, row, line_content, ctx)
---@diagnostic disable-next-line: undefined-field
if item._has_group_header then apply_group_header_highlights(item, ctx, buf, ns_id, row - 1) end
end
return M
+150
View File
@@ -0,0 +1,150 @@
local M = {}
local conf = require('fff.conf')
local layout = require('fff.layout')
local list_separator = require('fff.list_separator')
local scrollbar = require('fff.scrollbar')
local preview = require('fff.file_picker.preview')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
local S = picker_ui_state.state
local function restore_paste(should_restore)
if should_restore then vim.o.paste = true end
end
function M.relayout()
if not P.state.active then return end
local config = S.config
if not config then return end
local computed_layout = layout.compute(config, conf.preview_enabled(config))
local win_configs = computed_layout.win_configs
S.layout = computed_layout.layout
S.preview_visible = computed_layout.preview_visible
if S.list_win and vim.api.nvim_win_is_valid(S.list_win) then
vim.api.nvim_win_set_config(S.list_win, win_configs.list)
end
if S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_config(S.input_win, win_configs.input)
end
local preview_win_alive = S.preview_win and vim.api.nvim_win_is_valid(S.preview_win)
if S.preview_visible and win_configs.preview then
if preview_win_alive then
vim.api.nvim_win_set_config(S.preview_win, win_configs.preview)
else
P.open_preview(win_configs.preview)
end
elseif preview_win_alive then
P.close_preview()
end
local file_info_win_alive = S.file_info_win and vim.api.nvim_win_is_valid(S.file_info_win)
if win_configs.file_info then
if file_info_win_alive then
vim.api.nvim_win_set_config(S.file_info_win, win_configs.file_info)
else
S.file_info_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('filetype', 'fff_file_info', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.file_info_buf })
S.file_info_win = vim.api.nvim_open_win(S.file_info_buf, false, win_configs.file_info)
end
elseif file_info_win_alive then
vim.api.nvim_win_close(S.file_info_win, true)
S.file_info_win = nil
if S.file_info_buf and vim.api.nvim_buf_is_valid(S.file_info_buf) then
vim.api.nvim_buf_delete(S.file_info_buf, { force = true })
end
S.file_info_buf = nil
end
P.render_list()
P.update_preview()
P.update_status()
end
function M.close()
if not P.state.active then return end
vim.cmd('stopinsert')
P.state.active = false
restore_paste(S.restore_paste)
list_separator.cleanup()
scrollbar.cleanup()
local ts_ok, ts_hl = pcall(require, 'fff.treesitter_hl')
if ts_ok then ts_hl.cleanup() end
local windows = { S.input_win, S.list_win, S.preview_win }
if S.file_info_win then table.insert(windows, S.file_info_win) end
for _, win in ipairs(windows) do
if win and vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_close(win, true) end
end
local buffers = { S.input_buf, S.list_buf, S.file_info_buf }
if S.preview_buf then buffers[#buffers + 1] = S.preview_buf end
for _, buf in ipairs(buffers) do
if buf and vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, -1, 0, -1)
if buf == S.preview_buf then preview.clear_buffer(buf) end
vim.api.nvim_buf_delete(buf, { force = true })
end
end
P.close_preview_timer()
S.input_win = nil
S.list_win = nil
S.file_info_win = nil
S.preview_win = nil
S.input_buf = nil
S.list_buf = nil
S.file_info_buf = nil
S.preview_buf = nil
S.preview_visible = false
S.items = {}
S.filtered_items = {}
S.line_to_item = {}
S.item_to_lines = {}
S.last_render_ctx = nil
S.cursor = 1
S.query = ''
S.ns_id = nil
S.last_preview_file = nil
S.last_preview_location = nil
S.current_file_cache = nil
S.location = nil
S.selected_files = {}
S.selected_file_order = {}
S.selected_items = {}
S.mode = nil
S.grep_config = nil
S.grep_mode = 'plain'
S.grep_regex_fallback_error = nil
S.suggestion_items = nil
S.suggestion_source = nil
S.renderer = nil
S.restore_paste = false
S.combo_visible = true
S.combo_initial_cursor = nil
P.reset_history_state()
pcall(vim.api.nvim_del_augroup_by_name, 'fff_picker_focus')
end
return M
+258
View File
@@ -0,0 +1,258 @@
--- List Renderer, renders any type of list results produced by fff
local M = {}
--- @class ListRenderContext
--- @field config FffConfig User configuration
--- @field items table[] Array of data items to render
--- @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 Actual text area width (excluding signcolumn)
--- @field debug_enabled boolean Whether debug mode shows scores
--- @field prompt_position string 'top' or 'bottom'
--- @field has_combo boolean Whether combo boost is active
--- @field display_start number Start index for displayed items (1)
--- @field display_end number End index for displayed items (#items)
--- @field iter_start number Iteration start
--- @field iter_end number Iteration end
--- @field iter_step number Iteration step (1 or -1)
--- @field renderer table|nil Custom renderer with render_line/apply_highlights
--- @field query string Current search query
--- @field selected_files table<string, boolean> Selected file paths set
--- @field mode string|nil Current mode (nil or 'grep')
--- @field format_file_display function Helper for formatting file display
--- @field suggestion_source string|nil Active cross-mode suggestion source ('grep' or 'files')
--- @class ItemLineMapping
--- @field first number First buffer line (1-based) this item occupies
--- @field last number Last buffer line (1-based) — the selectable content line
--- @field virtual_count number Number of virtual (header) lines before the content line
--- @class ListRenderResult
--- @field lines string[] All buffer lines (including virtual rows and padding)
--- @field item_to_lines table<number, ItemLineMapping> Maps item index -> line range
--- @field padding_offset number Number of empty lines prepended for bottom prompt
--- @field total_content_lines number Lines before padding was applied
--- @field separator_line number|nil Buffer line (1-based) where separator sits, nil if none
--- @class ListSeparator
--- @field idx number 1-based item index — separator visually sits between this item and the previous one in the iteration order
--- @field text string Label text rendered inside the separator
--- @field text_hl string|nil Highlight group for label
--- @field border_hl string|nil Highlight group for the dashes
--- Generate all display lines from items from the renderer ctx
--- @param ctx table
--- @return string[] lines Array of line strings
--- @return table<number, ItemLineMapping> item_to_lines
--- @return number|nil separator_line 1-based buffer line of the separator, nil if none
local function generate_item_lines(ctx)
local lines = {}
local item_to_lines = {}
local separator_line = nil
-- Cross-mode suggestion header: rendered above items visually.
-- For top prompt that means before items; for bottom prompt after items
-- (because bottom prompt iterates in reverse).
local suggestion_header_lines = {}
local has_suggestion_header = ctx.suggestion_source ~= nil and #ctx.items > 0
if has_suggestion_header then
table.insert(suggestion_header_lines, '')
if ctx.mode == 'grep' and ctx.suggestion_source == 'files' then
-- Grep mode with no results — hint about mode cycling to fuzzy search
local config = require('fff.conf').get()
local keybind = config.keymaps.cycle_grep_modes
if type(keybind) == 'table' then keybind = keybind[1] or '<S-Tab>' end
table.insert(suggestion_header_lines, ' No results, try ' .. keybind .. ' to fuzzy search')
else
local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches'
table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':')
end
table.insert(suggestion_header_lines, '')
end
-- For top prompt: suggestion header goes before items
if has_suggestion_header and ctx.prompt_position ~= 'bottom' then
for _, hline in ipairs(suggestion_header_lines) do
table.insert(lines, hline)
end
end
local renderer = ctx.renderer
if not renderer then renderer = require('fff.picker_ui.file_renderer') end
-- Insert a gap on the side of the anchor away from the prompt.
-- Bottom prompt: gap BEFORE anchor in iter — anchor renders last with
-- reverse iter, so gap lands just above anchor visually.
-- Top prompt: gap AFTER anchor in iter — anchor renders first with
-- forward iter, so gap lands just below anchor visually.
-- Either way: anchor stays adjacent to the prompt, separator on far side.
local gap_before_anchor = ctx.prompt_position == 'bottom'
for i = ctx.iter_start, ctx.iter_end, ctx.iter_step do
if ctx.separator and ctx.separator.idx == i and gap_before_anchor then
table.insert(lines, '')
separator_line = #lines
end
local item = ctx.items[i]
local item_start_line = #lines + 1
local item_lines = renderer.render_line(item, ctx, i)
vim.list_extend(lines, item_lines)
local item_end_line = #lines
local virtual_count = item_end_line - item_start_line
item_to_lines[i] = {
first = item_start_line,
last = item_end_line,
virtual_count = virtual_count,
}
if ctx.separator and ctx.separator.idx == i and not gap_before_anchor then
table.insert(lines, '')
separator_line = #lines
end
end
-- For bottom prompt: suggestion header goes after items (appears above visually)
if has_suggestion_header and ctx.prompt_position == 'bottom' then
for _, hline in ipairs(suggestion_header_lines) do
table.insert(lines, hline)
end
end
return lines, item_to_lines, separator_line
end
--- Apply bottom padding: prepend empty lines so content sits at the bottom.
--- Adjusts all line indices in item_to_lines accordingly.
--- @param lines string[] Lines array (mutated)
--- @param item_to_lines table<number, ItemLineMapping> Mapping (mutated)
--- @param ctx table
--- @param separator_line number|nil Pre-padding separator line (mutated via return value)
--- @return number padding_offset Number of empty lines prepended
--- @return number|nil shifted_separator_line
local function apply_bottom_padding(lines, item_to_lines, ctx, separator_line)
if ctx.prompt_position ~= 'bottom' then return 0, separator_line 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
for _ = empty_lines_needed, 1, -1 do
table.insert(lines, 1, string.rep(' ', ctx.win_width + 5))
end
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
if separator_line then separator_line = separator_line + empty_lines_needed end
end
return empty_lines_needed, separator_line
end
--- Write lines to the buffer and position the cursor on the correct line.
--- The cursor always targets the content line (last) of the current item,
--- never a virtual header line.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx table
--- @param list_buf number Buffer handle
--- @param list_win number Window handle
--- @param ns_id number Namespace id
local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id)
-- Resolve cursor to a buffer line — always the content line (last), not virtual rows
local cursor_line = 0
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
vim.api.nvim_set_option_value('modifiable', true, { buf = list_buf })
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = list_buf })
vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1)
if #ctx.items > 0 and cursor_line > 0 and cursor_line <= #lines then
vim.api.nvim_win_set_cursor(list_win, { cursor_line, 0 })
end
end
--- Apply highlights for all items using the renderer's apply_highlights.
--- For each item, we pass the content line (last) to the renderer.
--- Renderers that emit virtual rows (grep_renderer) handle their own
--- header highlights internally via the item._has_group_header flag.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx table
--- @param list_buf number
--- @param ns_id number
local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
local renderer = ctx.renderer
if not renderer then renderer = require('fff.picker_ui.file_renderer') end
for i = ctx.display_start, ctx.display_end do
local item = ctx.items[i]
local item_lines = item_to_lines[i]
if item_lines then
-- The content line is always the last line in the mapping
local line_idx = item_lines.last
local line_content = lines[line_idx]
if line_content then
---@diagnostic disable-next-line: param-type-mismatch
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
end
end
end
end
--- Render the full item list into the buffer.
--- This is the main entry point — replaces the inline rendering in picker_ui.
---
--- @param ctx table Render context built by picker_ui
--- @param list_buf number List buffer handle
--- @param list_win number List window handle
--- @param ns_id number Highlight namespace
--- @return table<number, ItemLineMapping> item_to_lines
--- @return number|nil separator_line 1-based buffer line of the separator (post-padding), nil if none
function M.render(ctx, list_buf, list_win, ns_id)
local lines, item_to_lines, separator_line = generate_item_lines(ctx)
local _, shifted_separator_line = apply_bottom_padding(lines, item_to_lines, ctx, separator_line)
separator_line = shifted_separator_line
update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id)
if #ctx.items > 0 then apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id) end
-- Highlight the suggestion header lines (if present)
if ctx.suggestion_source and #ctx.items > 0 then
local suggestion_hl = ctx.config.hl.suggestion_header or 'WarningMsg'
for i = 0, #lines - 1 do
local line = lines[i + 1]
if line and (line:match('^%s+No results found') or line:match('^%s+No results,')) then
pcall(
vim.api.nvim_buf_set_extmark,
list_buf,
ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = suggestion_hl }
)
end
end
end
return item_to_lines, separator_line
end
return M
+289
View File
@@ -0,0 +1,289 @@
local M = {}
local list_separator = require('fff.list_separator')
local preview = require('fff.file_picker.preview')
local layout = require('fff.layout')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
local S = picker_ui_state.state
local function get_prompt_position() return layout.resolve_prompt_position(S.config) end
--- After cursor moves, decide whether the combo separator should hide.
local function maybe_hide_combo_separator()
if not (S.combo_initial_cursor and S.combo_visible) then return end
local distance_past = S.cursor - S.combo_initial_cursor
if distance_past == 0 then return end
local half_page = math.floor(S.pagination.page_size * 0.5)
if math.abs(distance_past) <= half_page then return end
S.combo_visible = false
list_separator.hide()
P.render_list()
if get_prompt_position() == 'bottom' then P.scroll_to_bottom() end
end
function M.wrap_to_first()
if S.pagination.page_index == 0 then
S.cursor = 1
return true
end
if S.mode ~= 'grep' then
return P.load_page_at_index(0, function() S.cursor = 1 end)
end
if S.pagination.grep_file_offsets[1] ~= nil then
return P.load_page_at_index(0, function() S.cursor = 1 end)
end
S.cursor = 1
return true
end
function M.wrap_to_last()
local page_size = S.pagination.page_size
if page_size == 0 then return false end
if S.mode ~= 'grep' then
local total = S.pagination.total_matched
if total == 0 then return false end
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
if S.pagination.page_index == max_page_index then
S.cursor = #S.filtered_items
return true
end
return P.load_page_at_index(max_page_index, function(result_count) S.cursor = result_count end)
end
S.cursor = #S.filtered_items
return true
end
function M.move_up()
if not P.state.active then return end
if #S.filtered_items == 0 then return end
local prompt_position = get_prompt_position()
local items_count = #S.filtered_items
local old_cursor = S.cursor
local wrap_around = S.config and S.config.wrap_around or false
if prompt_position == 'bottom' then
local near_bottom = S.cursor >= (items_count - S.pagination.prefetch_margin)
local at_last_item = S.cursor >= items_count
if near_bottom and at_last_item then
local page_size = S.pagination.page_size
local has_more = false
if page_size > 0 then
if S.mode == 'grep' then
has_more = S.pagination.grep_next_file_offset > 0
else
local max_page = math.max(0, math.ceil(S.pagination.total_matched / page_size) - 1)
has_more = S.pagination.page_index < max_page
end
end
if has_more then
P.load_next_page()
return
elseif wrap_around then
M.wrap_to_first()
end
else
S.cursor = math.min(S.cursor + 1, items_count)
end
else
if S.cursor <= S.pagination.prefetch_margin + 1 and S.cursor <= 1 then
if S.pagination.page_index > 0 then
vim.schedule(P.load_previous_page)
return
elseif wrap_around then
M.wrap_to_last()
end
else
S.cursor = math.max(S.cursor - 1, 1)
end
end
if not P.render_after_cursor_move(old_cursor) then return end
P.update_status()
pcall(vim.cmd, 'redraw')
P.update_preview_debounced()
maybe_hide_combo_separator()
end
function M.move_down()
if not P.state.active then return end
if #S.filtered_items == 0 then return end
local prompt_position = get_prompt_position()
local items_count = #S.filtered_items
local old_cursor = S.cursor
local wrap_around = S.config and S.config.wrap_around or false
if prompt_position == 'bottom' then
if S.cursor <= S.pagination.prefetch_margin + 1 and S.cursor <= 1 then
if S.pagination.page_index > 0 then
vim.schedule(P.load_previous_page)
return
elseif wrap_around then
M.wrap_to_last()
end
else
S.cursor = math.max(S.cursor - 1, 1)
end
else
local near_bottom = S.cursor >= (items_count - S.pagination.prefetch_margin)
local at_last_item = S.cursor >= items_count
if near_bottom and at_last_item then
local page_size = S.pagination.page_size
local has_more = false
if page_size > 0 then
if S.mode == 'grep' then
has_more = S.pagination.grep_next_file_offset > 0
else
local max_page = math.max(0, math.ceil(S.pagination.total_matched / page_size) - 1)
has_more = S.pagination.page_index < max_page
end
end
if has_more then
P.load_next_page()
return
elseif wrap_around then
M.wrap_to_first()
end
else
S.cursor = math.min(S.cursor + 1, items_count)
end
end
if not P.render_after_cursor_move(old_cursor) then return end
P.update_status()
pcall(vim.cmd, 'redraw')
P.update_preview_debounced()
maybe_hide_combo_separator()
end
function M.scroll_preview_up()
if not P.state.active or not S.preview_win then return end
local win_height = vim.api.nvim_win_get_height(S.preview_win)
local scroll_lines = math.floor(win_height / 2)
preview.scroll(-scroll_lines)
end
function M.scroll_preview_down()
if not P.state.active or not S.preview_win then return end
local win_height = vim.api.nvim_win_get_height(S.preview_win)
local scroll_lines = math.floor(win_height / 2)
preview.scroll(scroll_lines)
end
-- Helper function to eliminate UI update redundancy
local function update_ui_after_jump(old_cursor)
if not P.render_after_cursor_move(old_cursor) then return false end
P.update_status()
pcall(vim.cmd, 'redraw')
P.update_preview_debounced()
return true
end
function M.grep_jump_to_next_file()
if not P.state.active or S.mode ~= 'grep' then return end
local items = S.filtered_items
if not items or #items == 0 then return end
local old_cursor = S.cursor
local current_path = items[S.cursor] and items[S.cursor].relative_path
for i = S.cursor + 1, #items do
if items[i].relative_path ~= current_path then
S.cursor = i
update_ui_after_jump(old_cursor)
return
end
end
if P.load_next_page and P.load_next_page() then
local new_items = S.filtered_items
if new_items and #new_items > 0 then
local idx = 1
if new_items[1].relative_path == current_path then
for i = 2, #new_items do
if new_items[i].relative_path ~= current_path then
idx = i
break
end
end
end
S.cursor = idx
update_ui_after_jump(old_cursor)
end
end
end
function M.grep_jump_to_prev_file()
if not P.state.active or S.mode ~= 'grep' then return end
local items = S.filtered_items
if not items or #items == 0 then return end
local old_cursor = S.cursor
local current_path = items[S.cursor] and items[S.cursor].relative_path
for i = S.cursor - 1, 1, -1 do
if items[i].relative_path ~= current_path then
local target_idx = i
while target_idx > 1 and items[target_idx - 1].relative_path == items[i].relative_path do
target_idx = target_idx - 1
end
S.cursor = target_idx
update_ui_after_jump(old_cursor)
return
end
end
if P.load_previous_page and P.load_previous_page() then
local new_items = S.filtered_items
if not new_items or #new_items == 0 then return end
local target_path = nil
for i = #new_items, 1, -1 do
if new_items[i].relative_path ~= current_path then
target_path = new_items[i].relative_path
break
end
end
target_path = target_path or new_items[#new_items].relative_path
local first = 1
for i = 1, #new_items do
if new_items[i].relative_path == target_path then
first = i
break
end
end
S.cursor = first
update_ui_after_jump(old_cursor)
end
end
return M
+778
View File
@@ -0,0 +1,778 @@
local M = {}
local conf = require('fff.conf')
local file_picker = require('fff.file_picker')
local preview = require('fff.file_picker.preview')
local utils = require('fff.utils')
local location_utils = require('fff.location_utils')
local layout = require('fff.layout')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
local picker_ui_utils = require('fff.picker_ui.utils')
local ui_creator = require('fff.picker_ui.ui_creator')
local search_manager = require('fff.picker_ui.search_manager')
local renderer = require('fff.picker_ui.renderer')
local preview_manager = require('fff.picker_ui.preview_manager')
local navigation = require('fff.picker_ui.navigation')
local layout_manager = require('fff.picker_ui.layout_manager')
local canonicalize_fff_path = utils.canonicalize_fff_path
local preview_config = conf.get().preview
if preview_config then preview.setup(preview_config) end
local function get_prompt_position() return layout.resolve_prompt_position(M.state.config) end
-- Wire state from picker_ui_state module
M.state = picker_ui_state.state
-- Alias pure state functions from picker_ui_state
M.clear_selections = picker_ui_state.clear_selections
M.reset_history_state = picker_ui_state.reset_history_state
-- Wire ui_creator module (UI creation, window/buffer/keymap setup)
ui_creator.init(M)
M.create_ui = ui_creator.create_ui
M.setup_buffers = ui_creator.setup_buffers
M.setup_windows = ui_creator.setup_windows
M.setup_keymaps = ui_creator.setup_keymaps
M.focus_list_win = ui_creator.focus_list_win
M.focus_preview_win = ui_creator.focus_preview_win
M.focus_input_win = ui_creator.focus_input_win
M.open_preview = ui_creator.open_preview
M.close_preview = ui_creator.close_preview
-- Wire search_manager module (search, pagination, history)
search_manager.init(M)
M.update_results_sync = search_manager.update_results_sync
M.update_results = search_manager.update_results
M.load_page_at_index = search_manager.load_page_at_index
M.load_next_page = search_manager.load_next_page
M.load_previous_page = search_manager.load_previous_page
M.on_input_change = search_manager.on_input_change
M.cycle_grep_modes = search_manager.cycle_grep_modes
M.recall_query_from_history = search_manager.recall_query_from_history
M.cycle_forward_query = search_manager.cycle_forward_query
M.get_suggestion_renderer = search_manager.get_suggestion_renderer
-- Wire renderer module (list rendering, scroll, empty state)
renderer.init(M)
M.render_list = renderer.render_list
M.render_after_cursor_move = renderer.render_after_cursor_move
-- Wire preview_manager module (preview rendering, debounce, clear)
preview_manager.init(M)
M.close_preview_timer = preview_manager.close_preview_timer
M.update_preview_debounced = preview_manager.update_preview_debounced
M.update_preview_smart = preview_manager.update_preview_smart
M.update_preview_title = preview_manager.update_preview_title
M.update_preview = preview_manager.update_preview
M.clear_preview = preview_manager.clear_preview
-- Wire navigation module (cursor movement, preview scroll, pagination wraparound)
navigation.init(M)
M.wrap_to_first = navigation.wrap_to_first
M.wrap_to_last = navigation.wrap_to_last
M.move_up = navigation.move_up
M.move_down = navigation.move_down
M.scroll_preview_up = navigation.scroll_preview_up
M.scroll_preview_down = navigation.scroll_preview_down
M.grep_jump_to_next_file = navigation.grep_jump_to_next_file
M.grep_jump_to_prev_file = navigation.grep_jump_to_prev_file
-- Expose helpers used by navigation
M.scroll_to_bottom = renderer.scroll_to_bottom
-- Wire layout_manager module (relayout, close)
layout_manager.init(M)
M.relayout = layout_manager.relayout
--- @class fff.ResumeState
--- @field files table|nil Snapshot from last find_files session
--- @field grep table|nil Snapshot from last live_grep session
--- @field last_mode 'files'|'grep'|nil Mode of the most recently closed picker
local resume_state = { files = nil, grep = nil, last_mode = nil }
--- Save the current picker state for later resume, then close.
function M.close()
if M.state.query == '' then
layout_manager.close()
return
end
if not M.state.active then return end
local snapshot = vim.deepcopy(M.state)
snapshot.base_path = M.state.config and M.state.config.base_path or nil
if M.state.mode == 'grep' then
resume_state.grep = snapshot
resume_state.last_mode = 'grep'
else
resume_state.files = snapshot
resume_state.last_mode = 'files'
end
layout_manager.close()
end
--- Internal: restore picker from a saved state snapshot.
---@param state table The saved state table
---@param source_label string Label for error messages
---@return boolean
local function restore_from_state(state, source_label)
-- Ensure the file picker is initialized
if not file_picker.is_initialized() then
if not file_picker.setup() then
vim.notify('Failed to initialize file picker', vim.log.levels.ERROR)
return false
end
end
-- Restore the picker with the saved config and mode
M.state.renderer = state.renderer
M.state.mode = state.mode
M.state.grep_config = state.grep_config
M.state.grep_mode = state.grep_mode
M.state.selected_files = vim.deepcopy(state.selected_files or {})
M.state.selected_file_order = vim.deepcopy(state.selected_file_order or {})
M.state.selected_items = vim.deepcopy(state.selected_items or {})
-- Restore the saved base_path for the indexer if it differs from the current CWD
if state.base_path then require('fff.core').change_indexing_directory(state.base_path) end
-- Use the saved config directly to restore the exact picker state
M.state.config = state.config
if not M.create_ui() then
vim.notify('FFF: failed to create picker UI for ' .. source_label, vim.log.levels.ERROR)
return false
end
M.state.active = true
M.state.current_file_cache = state.current_file_cache
-- Restore the full picker state
M.state.query = state.query
M.state.items = state.items or {}
M.state.filtered_items = state.filtered_items or {}
M.state.cursor = math.min(state.cursor or 1, #(state.filtered_items or {}))
M.state.cursor = math.max(M.state.cursor, 1)
M.state.location = state.location
M.state.pagination = vim.deepcopy(state.pagination or {
page_index = 0,
page_size = 20,
total_matched = 0,
prefetch_margin = 5,
grep_file_offsets = {},
grep_next_file_offset = 0,
})
M.state.combo_visible = state.combo_visible ~= false
M.state.combo_initial_cursor = state.combo_initial_cursor
M.state.suggestion_items = state.suggestion_items
M.state.suggestion_source = state.suggestion_source
-- Writing the query below triggers on_lines -> on_input_change, which re-runs
-- the search (results may have changed since close). Stash the saved cursor so
-- that re-search restores the position instead of resetting it to the top.
if state.query and state.query ~= '' then
M.state.pending_restore_cursor = M.state.cursor
vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. state.query })
end
-- Render the restored state
M.render_list()
M.update_preview()
M.update_status()
vim.api.nvim_set_current_win(M.state.input_win)
-- Position cursor at end of query
vim.schedule(function()
if M.state.active and M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then
local prompt_len = #M.state.config.prompt
vim.api.nvim_win_set_cursor(M.state.input_win, { 1, prompt_len + #state.query })
vim.cmd('stopinsert')
end
end)
return true
end
--- Close any active picker before resuming so the user can re-trigger
--- resume to recreate the previous results without manually closing first.
local function close_active_for_resume()
if M.state.active then layout_manager.close() end
end
---@return boolean|nil true if a picker was resumed, false otherwise
function M.resume()
close_active_for_resume()
if resume_state.last_mode == 'grep' then
return M.resume_live_grep()
elseif resume_state.last_mode == 'files' then
return M.resume_find_files()
end
if resume_state.grep then return restore_from_state(resume_state.grep, 'grep resume') end
if resume_state.files then return restore_from_state(resume_state.files, 'files resume') end
return M.open()
end
--- Resume the last file picker (find_files mode).
--- Falls back to opening a new find_files picker if nothing to resume.
---@param opts? table Optional config overrides for fallback open
---@return boolean|nil
function M.resume_find_files(opts)
close_active_for_resume()
if not resume_state.files then return M.open(opts) end
return restore_from_state(resume_state.files, 'find_files resume')
end
--- Resume the last live_grep picker.
--- Falls back to opening a new live_grep picker if nothing to resume.
---@param opts? table Optional config overrides for fallback open
---@return boolean
function M.resume_live_grep(opts)
close_active_for_resume()
if not resume_state.grep then
local config = conf.get()
local grep_renderer = require('fff.picker_ui.grep_renderer')
local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {})
M.open(vim.tbl_deep_extend('force', {
mode = 'grep',
renderer = grep_renderer,
grep_config = grep_config,
title = 'Live Grep',
}, opts or {}))
return true
end
return restore_from_state(resume_state.grep, 'live_grep resume')
end
function M.toggle_debug()
local config_changed = conf.toggle_debug()
if config_changed then
local current_query = M.state.query
local current_items = M.state.items
local current_cursor = M.state.cursor
-- Preserve mode-specific state across close/open cycle
local current_mode = M.state.mode
local current_renderer = M.state.renderer
local current_grep_mode = M.state.grep_mode
local current_grep_config = M.state.grep_config
local current_filtered_items = M.state.filtered_items
local current_selected_files = M.state.selected_files
local current_selected_file_order = M.state.selected_file_order
local current_selected_items = M.state.selected_items
M.close()
M.open({
mode = current_mode,
renderer = current_renderer,
grep_config = current_grep_config,
})
M.state.query = current_query
M.state.items = current_items
M.state.cursor = current_cursor
M.state.grep_mode = current_grep_mode
M.state.filtered_items = current_filtered_items
M.state.selected_files = current_selected_files
M.state.selected_file_order = current_selected_file_order
M.state.selected_items = current_selected_items
M.render_list()
M.update_preview()
M.update_status()
vim.schedule(function()
if M.state.active and M.state.input_win then
vim.api.nvim_set_current_win(M.state.input_win)
vim.cmd('startinsert!')
end
end)
else
M.update_results()
end
end
function M.render_debounced()
vim.schedule(function()
if M.state.active then
M.render_list()
M.update_preview()
M.update_status()
end
end)
end
--- Update status information on the right side of input using virtual text
function M.update_status(progress)
if not M.state.active or not M.state.ns_id then return end
local config = M.state.config
if config == nil then return end
if M.state.mode == 'grep' then
-- Determine available modes to decide if we should show the mode indicator
-- Use grep_config.modes if provided, otherwise fall back to global config
---@diagnostic disable-next-line: undefined-field
local modes = (M.state.grep_config and M.state.grep_config.modes)
or config.grep.modes
or { 'plain', 'regex', 'fuzzy' }
-- When regex compilation failed and we fell back to literal search, show a warning
local fallback_label = nil
if M.state.grep_regex_fallback_error then fallback_label = 'invalid regex, using literal' end
-- If only one mode configured and no fallback error, hide the mode indicator completely
if #modes <= 1 and not fallback_label then
-- Clear any existing status and don't show anything
vim.api.nvim_buf_clear_namespace(M.state.input_buf, M.state.ns_id, 0, -1)
M.state.last_status_info = nil
return
end
local keybind = config.keymaps.cycle_grep_modes
-- Normalize: if it's a table of keys, use the first one for display
if type(keybind) == 'table' then keybind = keybind[1] or '<S-Tab>' end
local mode_labels = {
plain = 'plain',
regex = 'regex',
fuzzy = 'fuzzy',
}
local mode_label = mode_labels[M.state.grep_mode] or 'plain'
local hl
if M.state.grep_mode == 'plain' then
hl = config.hl.grep_plain_active or 'Comment'
elseif M.state.grep_mode == 'regex' then
hl = config.hl.grep_regex_active or 'DiagnosticInfo'
else -- fuzzy
hl = config.hl.grep_fuzzy_active or 'DiagnosticHint'
end
local cache_key = keybind .. M.state.grep_mode .. (fallback_label or '')
if cache_key == M.state.last_status_info then return end
M.state.last_status_info = cache_key
vim.api.nvim_buf_clear_namespace(M.state.input_buf, M.state.ns_id, 0, -1)
local win_width = vim.api.nvim_win_get_width(M.state.input_win)
local available_width = win_width - 2
if fallback_label then
local total_len = #fallback_label
local col_position = available_width - total_len
vim.api.nvim_buf_set_extmark(M.state.input_buf, M.state.ns_id, 0, 0, {
virt_text = { { fallback_label, 'DiagnosticWarn' } },
virt_text_win_col = col_position,
})
else
local total_len = #keybind + 1 + #mode_label
local col_position = available_width - total_len
vim.api.nvim_buf_set_extmark(M.state.input_buf, M.state.ns_id, 0, 0, {
virt_text = {
{ keybind .. ' ', hl },
{ mode_label, hl },
},
virt_text_win_col = col_position,
})
end
return
end
-- File picker mode: show match counts
local status_info
if progress and progress.is_scanning then
status_info = string.format('Indexing files %d', progress.scanned_files_count)
else
local search_metadata = file_picker.get_search_metadata()
if #M.state.query < 2 then
status_info = string.format('%d', search_metadata.total_files)
else
status_info = string.format('%d/%d', search_metadata.total_matched, search_metadata.total_files)
end
end
if status_info == M.state.last_status_info then return end
M.state.last_status_info = status_info
vim.api.nvim_buf_clear_namespace(M.state.input_buf, M.state.ns_id, 0, -1)
local win_width = vim.api.nvim_win_get_width(M.state.input_win)
local available_width = win_width - 2
local col_position = available_width - #status_info
vim.api.nvim_buf_set_extmark(M.state.input_buf, M.state.ns_id, 0, 0, {
virt_text = { { status_info, 'LineNr' } },
virt_text_win_col = col_position,
})
end
--- Check whether the given window has 'winfixbuf' enabled.
--- pcall-guarded so this stays safe on Neovim versions that predate the option.
local window_has_winfixbuf = utils.window_has_winfixbuf
--- Toggle selection for the current item.
--- In grep mode, selection is per-occurrence; in file mode, per-file.
function M.toggle_select()
if not M.state.active then return end
local was_selected = picker_ui_state.toggle_selection()
M.render_list()
-- only when selecting the element not deselecting
if not was_selected then
if get_prompt_position() == 'bottom' then
M.move_up()
else
M.move_down()
end
end
end
M.send_to_quickfix = picker_ui_utils.send_to_quickfix
function M.select(action)
if not M.state.active then return end
local items = M.state.filtered_items
if #items == 0 or M.state.cursor > #items then return end
---@diagnostic disable-next-line: need-check-nil
local item = items[M.state.cursor]
if not item then return end
action = action or 'edit'
-- Anchor against the indexer's base_path (may differ from cwd), then rephrase
-- as cwd-relative for a nicer buffer name when possible. When outside cwd,
-- fnamemodify(':.') leaves the absolute path intact.
local abs_path = canonicalize_fff_path(item.relative_path)
if not abs_path then return end
local relative_path = vim.fn.fnamemodify(abs_path, ':.')
local location = M.state.location -- Capture location before closing
local query = M.state.query -- Capture query before closing for tracking
local mode = M.state.mode -- Capture mode before closing for tracking
local suggestion_source = M.state.suggestion_source -- Capture suggestion context
local config = M.state.config -- Capture config before M.close() resets state
-- In grep mode (or when selecting a grep suggestion), derive location from the match item
local is_grep_item = mode == 'grep' or suggestion_source == 'grep'
-- When opening with selections active, open every selected file. The first
-- selected file is focused; the rest are added as listed buffers.
local selected_file_entries = {}
if action == 'edit' and not is_grep_item then selected_file_entries = picker_ui_state.get_selected_file_entries() end
if is_grep_item and item.line_number and item.line_number > 0 then
location = { line = item.line_number }
if item.col and item.col > 0 then
location.col = item.col + 1 -- Convert 0-based byte col to 1-based
end
end
-- Fallback: if location is nil but query has a :line suffix, parse it directly
if not location and query and query ~= '' then
local line_str = query:match(':(%d+)$')
if line_str then
local line_num = tonumber(line_str)
if line_num and line_num > 0 then
local col_and_line = query:match(':(%d+):(%d+)$')
if col_and_line then
local l, c = query:match(':(%d+):(%d+)$')
location = { line = tonumber(l), col = tonumber(c) }
else
location = { line = line_num }
end
end
end
end
-- The focused file is the first selection, which may differ from the cursor
-- item; a cursor-derived location no longer applies, so drop it.
if #selected_file_entries > 0 and selected_file_entries[1].relative_path ~= item.relative_path then location = nil end
vim.cmd('stopinsert')
M.close()
local on_submit = config and config.on_submit
-- Defer file open past picker float teardown. Without this, foldexpr is not
-- recomputed on the new window (folds appear missing) on some platforms.
vim.schedule(function()
if type(on_submit) == 'function' then
local ok, err = pcall(on_submit, item, {
action = action,
path = abs_path,
relative_path = relative_path,
location = location,
query = query,
mode = mode,
})
if not ok then vim.notify('FFF: on_submit error: ' .. tostring(err), vim.log.levels.ERROR) end
else
if config and config.select and type(config.select.select_window) == 'function' then
local ok, win = pcall(config.select.select_window, vim.api.nvim_get_current_buf(), action)
if not ok then
vim.notify('FFF: select.select_window error: ' .. tostring(win), vim.log.levels.WARN)
elseif type(win) == 'number' and vim.api.nvim_win_is_valid(win) then
vim.api.nvim_set_current_win(win)
end
end
if action == 'edit' then
-- Add every additional selection as a listed buffer before focusing one.
for _, entry in ipairs(selected_file_entries) do
local buf = vim.fn.bufadd(entry.edit_path)
vim.bo[buf].buflisted = true
end
local edit_path = #selected_file_entries > 0 and selected_file_entries[1].edit_path or relative_path
-- Hard guard against E1513 ("Cannot switch buffer. 'winfixbuf' is enabled"):
-- if the (post-hook) current window is pinned, fall back to :split.
local opened_via_split = false
if window_has_winfixbuf(vim.api.nvim_get_current_win()) then
vim.cmd('split ' .. vim.fn.fnameescape(edit_path))
opened_via_split = true
end
if not opened_via_split then vim.cmd('edit ' .. vim.fn.fnameescape(edit_path)) end
elseif action == 'split' then
vim.cmd('split ' .. vim.fn.fnameescape(relative_path))
elseif action == 'vsplit' then
vim.cmd('vsplit ' .. vim.fn.fnameescape(relative_path))
elseif action == 'tab' then
vim.cmd('tabedit ' .. vim.fn.fnameescape(relative_path))
end
if location then location_utils.jump_to_location(location) end
end
if query and query ~= '' then
local cfg = config or conf.get()
if cfg.history and cfg.history.enabled then
local fff = require('fff.core').ensure_initialized()
-- Track in background thread (non-blocking, handled by Rust)
if mode == 'grep' then
pcall(fff.track_grep_query, query)
elseif #selected_file_entries > 0 then
for _, entry in ipairs(selected_file_entries) do
pcall(fff.track_query_completion, query, entry.relative_path)
end
else
pcall(fff.track_query_completion, query, item.relative_path)
end
end
end
end)
end
--- @return string|nil Current file cache path
local function get_current_file_cache(base_path)
if not base_path then return nil end
local current_buf = vim.api.nvim_get_current_buf()
if not current_buf or not vim.api.nvim_buf_is_valid(current_buf) then return nil end
local current_file = vim.api.nvim_buf_get_name(current_buf)
if current_file == '' then return nil end
-- Use vim.uv.fs_stat to check if file exists and is readable
local stat = vim.uv.fs_stat(current_file)
if not stat or stat.type ~= 'file' then return nil end
local absolute_path = vim.fn.fnamemodify(current_file, ':p')
local resolved_abs = vim.fn.resolve(absolute_path)
local resolved_base = vim.fn.resolve(base_path)
-- icloud direcrtoes on macos contain a lot of special characters that break
-- the fnamemodify which have to escaped with %
local escaped_base = resolved_base:gsub('([%%^$()%.%[%]*+%-?])', '%%%1')
local relative_path = resolved_abs:gsub('^' .. escaped_base .. '/', '')
if relative_path == '' or relative_path == resolved_abs then return nil end
return relative_path
end
--- Helper function for common picker initialization
--- @param opts table|nil Options passed to the picker
--- @return table|nil, string|nil Merged configuration and base path, nil config if initialization failed
local function initialize_picker(opts)
local base_path = opts and opts.cwd or vim.uv.cwd()
-- Initialize file picker if needed
if not file_picker.is_initialized() then
if not file_picker.setup() then
vim.notify('Failed to initialize file picker', vim.log.levels.ERROR)
return nil
end
end
local config = conf.get()
local merged_config = vim.tbl_deep_extend('force', config or {}, opts or {})
return merged_config, base_path
end
--- Helper function to open UI with optional prefetched results
--- @param query string|nil Pre-filled query (nil for empty)
--- @param results table|nil Pre-fetched results (nil to search normally)
--- @param location table|nil Pre-fetched location data
--- @param merged_config table Merged configuration
--- @param current_file_cache string|nil Current file cache
local function open_ui_with_state(query, results, location, merged_config, current_file_cache)
M.state.config = merged_config
if not M.create_ui() then
vim.notify('Failed to create picker UI', vim.log.levels.ERROR)
return false
end
M.state.active = true
M.state.current_file_cache = current_file_cache
-- Set up initial state
if query then
M.state.query = query
vim.api.nvim_buf_set_lines(M.state.input_buf, 0, -1, false, { M.state.config.prompt .. query })
else
M.state.query = ''
end
if results then
-- Use prefetched results
M.state.items = results
M.state.filtered_items = results
M.state.cursor = #results > 0 and 1 or 1
M.state.location = location
M.render_list()
M.update_preview()
M.update_status()
else
M.update_results()
M.clear_preview()
M.update_status()
end
vim.api.nvim_set_current_win(M.state.input_win)
-- Position cursor at end of query if there is one
if query then
vim.schedule(function()
if M.state.active and M.state.input_win and vim.api.nvim_win_is_valid(M.state.input_win) then
vim.api.nvim_win_set_cursor(M.state.input_win, { 1, #M.state.config.prompt + #query })
vim.cmd('startinsert!')
end
end)
else
vim.cmd('startinsert!')
end
M.monitor_scan_progress(0)
return true
end
--- Execute a search query with callback handling before potentially opening the UI
--- @param query string The search query to execute
--- @param callback function Function called with results: function(results, metadata, location, get_file_score) -> boolean
--- @param opts? table Optional configuration to override defaults (same as M.open)
--- @return boolean true if callback handled results, false if UI was opened
function M.open_with_callback(query, callback, opts)
if M.state.active then return false end
-- open_with_callback runs the file-picker flow, never grep. Reset the
-- renderer/mode/grep_config defensively so we can't inherit stale state
-- from a previous live_grep session (close() must always do this too,
-- but belt-and-braces).
M.state.renderer = nil
M.state.mode = nil
M.state.grep_config = nil
local merged_config, base_path = initialize_picker(opts)
if not merged_config then return false end
local current_file_cache = get_current_file_cache(base_path)
local results = file_picker.search_files(query, current_file_cache, nil, nil, nil)
local metadata = file_picker.get_search_metadata()
local location = file_picker.get_search_location()
local callback_handled = false
if type(callback) == 'function' then
local ok, result = pcall(callback, results, metadata, location, file_picker.get_file_score)
if ok then
callback_handled = result == true
else
vim.notify('Error in search callback: ' .. tostring(result), vim.log.levels.ERROR)
end
end
if callback_handled then return true end
open_ui_with_state(query, results, location, merged_config, current_file_cache)
return false
end
--- Open the file picker UI
--- @param opts? {cwd?: string, title?: string, prompt?: string, max_results?: number, max_threads?: number, layout?: {width?: number|function, height?: number|function, prompt_position?: string|function, preview_position?: string|function, preview_size?: number|function}, renderer?: table, mode?: string, grep_config?: table, query?: string} Optional configuration to override defaults
function M.open(opts)
if M.state.active then return end
M.state.selected_files = {}
M.state.selected_file_order = {}
M.state.selected_items = {}
M.state.renderer = opts and opts.renderer or nil
M.state.mode = opts and opts.mode or nil
M.state.grep_config = opts and opts.grep_config or nil
local merged_config, base_path = initialize_picker(opts)
if not merged_config then return end
if base_path then require('fff.core').change_indexing_directory(base_path) end
-- Initialize grep_mode to first configured mode when opening in grep mode
if M.state.mode == 'grep' then
-- Use grep_config.modes if provided, otherwise fall back to global config
---@diagnostic disable-next-line: undefined-field
local modes = (M.state.grep_config and M.state.grep_config.modes)
or merged_config.grep.modes
or { 'plain', 'regex', 'fuzzy' }
M.state.grep_mode = modes[1] or 'plain'
end
local current_file_cache = get_current_file_cache(base_path)
local query = opts and opts.query or nil ---@type string|nil
return open_ui_with_state(query, nil, nil, merged_config, current_file_cache)
end
function M.monitor_scan_progress(iteration)
if not M.state.active then return end
local progress = file_picker.get_scan_progress()
if progress.is_scanning then
M.update_status(progress)
-- progressive decay for larger directories
local timeout
if iteration < 10 then
timeout = 100
elseif iteration < 20 then
timeout = 300
else
timeout = 500
end
vim.defer_fn(function() M.monitor_scan_progress(iteration + 1) end, timeout)
else
M.update_results()
end
end
return M
+260
View File
@@ -0,0 +1,260 @@
local M = {}
local utils = require('fff.utils')
local canonicalize_fff_path = utils.canonicalize_fff_path
-- State structure definitions
M.state = {
-- UI state
active = false,
layout = nil,
input_win = nil,
input_buf = nil,
list_win = nil,
list_buf = nil,
file_info_win = nil,
file_info_buf = nil,
preview_win = nil,
preview_buf = nil,
preview_visible = false,
-- Data state
items = {},
filtered_items = {},
cursor = 1,
top = 1,
query = '',
line_to_item = {},
item_to_lines = {},
last_render_ctx = nil,
location = nil,
-- Cursor index to restore after the next search completes (set on resume).
-- Lets the re-search run for fresh results while keeping the saved position.
pending_restore_cursor = nil,
-- History cycling state
history_offset = nil,
next_search_force_combo_boost = false,
-- Combo state
combo_visible = true,
combo_initial_cursor = nil,
-- History cycling state (tracked alongside combo state)
updating_from_history = false,
-- Pagination state
pagination = {
page_index = 0,
page_size = 20,
total_matched = 0,
prefetch_margin = 5,
grep_file_offsets = {},
grep_next_file_offset = 0,
},
-- Configuration and mode
config = nil,
renderer = nil,
mode = nil,
grep_mode = 'plain',
grep_config = nil,
grep_regex_fallback_error = nil,
-- Selection state
selected_files = {},
selected_file_order = {},
selected_items = {},
-- Cross-mode suggestion state
suggestion_items = nil,
suggestion_source = nil,
-- Preview state
last_preview_file = nil,
last_preview_location = nil,
preview_timer = nil,
preview_debounce_ms = 10,
-- Misc
ns_id = nil,
last_status_info = nil,
restore_paste = false,
current_file_cache = nil,
}
-- Helper function to generate grep item keys
local function grep_item_key(item)
return string.format('%s:%d:%d', item.relative_path, item.line_number or 1, item.col or 0)
end
-- Reset history-related state
function M.reset_history_state()
M.state.history_offset = nil
M.state.next_search_force_combo_boost = false
M.state.combo_visible = true
M.state.combo_initial_cursor = nil
M.state.updating_from_history = false
end
-- Complete state reset (called on close)
function M.reset_state()
M.state.active = false
M.state.layout = nil
M.state.input_win = nil
M.state.input_buf = nil
M.state.list_win = nil
M.state.list_buf = nil
M.state.file_info_win = nil
M.state.file_info_buf = nil
M.state.preview_win = nil
M.state.preview_buf = nil
M.state.preview_visible = false
M.state.items = {}
M.state.filtered_items = {}
M.state.cursor = 1
M.state.top = 1
M.state.query = ''
M.state.line_to_item = {}
M.state.item_to_lines = {}
M.state.last_render_ctx = nil
M.state.location = nil
M.state.pending_restore_cursor = nil
M.reset_history_state()
M.state.pagination = {
page_index = 0,
page_size = 20,
total_matched = 0,
prefetch_margin = 5,
grep_file_offsets = {},
grep_next_file_offset = 0,
}
M.state.config = nil
M.state.renderer = nil
M.state.mode = nil
M.state.grep_mode = 'plain'
M.state.grep_config = nil
M.state.grep_regex_fallback_error = nil
M.state.selected_files = {}
M.state.selected_file_order = {}
M.state.selected_items = {}
M.state.suggestion_items = nil
M.state.suggestion_source = nil
M.state.last_preview_file = nil
M.state.last_preview_location = nil
M.state.preview_timer = nil
M.state.current_file_cache = nil
M.state.ns_id = nil
M.state.last_status_info = nil
M.state.restore_paste = false
M.state.combo_visible = true
M.state.combo_initial_cursor = nil
M.state.updating_from_history = false
end
-- Clear all selections
function M.clear_selections()
M.state.selected_files = {}
M.state.selected_file_order = {}
M.state.selected_items = {}
end
-- Get selected items for quickfix list
function M.get_selected_items()
local selected = {}
if M.state.mode == 'grep' then
-- Grep mode: return selected items
for _, item in pairs(M.state.selected_items) do
table.insert(selected, item)
end
else
-- Normal file mode: return selected files
for relative_path, _ in pairs(M.state.selected_files) do
local abs_path = canonicalize_fff_path(relative_path)
if abs_path then table.insert(selected, { relative_path = relative_path }) end
end
end
return selected
end
-- Toggle item selection (called from keymaps via M.toggle_select)
function M.toggle_selection()
if not M.state.active then return end
local items = M.state.filtered_items
if #items == 0 or M.state.cursor > #items then return end
local item = items[M.state.cursor]
if not item or not item.relative_path then return end
local was_selected
if M.state.mode == 'grep' then
-- Per-occurrence selection for grep mode
local key = grep_item_key(item)
was_selected = M.state.selected_items[key] ~= nil
if was_selected then
M.state.selected_items[key] = nil
else
M.state.selected_items[key] = item
end
else
-- Per-file selection for normal file mode
was_selected = M.state.selected_files[item.relative_path]
if was_selected then
M.state.selected_files[item.relative_path] = nil
M.state.selected_file_order = vim.tbl_filter(
function(path) return path ~= item.relative_path end,
M.state.selected_file_order
)
else
M.state.selected_files[item.relative_path] = true
table.insert(M.state.selected_file_order, item.relative_path)
end
end
return was_selected
end
-- Ordered, deduped selected file entries for opening (file mode only).
-- Each entry has the raw fff relative_path and a cwd-relative edit_path.
function M.get_selected_file_entries()
if not next(M.state.selected_files) then return {} end
local entries = {}
local seen = {}
local function add(relative_path)
if not relative_path or seen[relative_path] or not M.state.selected_files[relative_path] then return end
local abs_path = canonicalize_fff_path(relative_path)
if not abs_path then return end
seen[relative_path] = true
table.insert(entries, {
relative_path = relative_path,
edit_path = vim.fn.fnamemodify(abs_path, ':.'),
})
end
for _, relative_path in ipairs(M.state.selected_file_order) do
add(relative_path)
end
for relative_path, _ in pairs(M.state.selected_files) do
add(relative_path)
end
return entries
end
return M
+236
View File
@@ -0,0 +1,236 @@
local M = {}
local preview = require('fff.file_picker.preview')
local utils = require('fff.utils')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
local canonicalize_fff_path = utils.canonicalize_fff_path
-- Parent module reference (set by picker_ui.lua during initialization).
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
local S = picker_ui_state.state
function M.close_preview_timer(timer)
timer = timer or S.preview_timer
if not timer then return end
if S.preview_timer == timer then S.preview_timer = nil end
if timer:is_closing() then return end
timer:stop()
timer:close()
end
function M.update_preview_debounced()
M.close_preview_timer()
local timer = vim.uv.new_timer()
S.preview_timer = timer
timer:start(
S.preview_debounce_ms,
0,
vim.schedule_wrap(function()
local is_current = S.preview_timer == timer
M.close_preview_timer(timer)
if is_current and P.state.active then M.update_preview() end
end)
)
end
--- Smart preview update for cursor movement.
function M.update_preview_smart()
if not S.preview_visible then return end
if not P.state.active then return end
local items = S.filtered_items
if #items == 0 or S.cursor > #items then
M.update_preview()
return
end
local item = items[S.cursor]
if not item then
M.update_preview()
return
end
if S.last_preview_file == item.relative_path then
M.update_preview()
return
end
M.update_preview_debounced()
end
function M.update_preview_title(item, location)
if not S.preview_win or not vim.api.nvim_win_is_valid(S.preview_win) then return end
local relative_path = item.relative_path
local max_title_width = vim.api.nvim_win_get_width(S.preview_win)
local suffix = ''
local is_grep_item = S.mode == 'grep' or S.suggestion_source == 'grep'
if is_grep_item and location and location.line then suffix = ':' .. tostring(location.line) end
local display_path = relative_path .. suffix
local title
if #display_path + 2 <= max_title_width then
title = string.format(' %s ', display_path)
else
local available_chars = max_title_width - 2
local filename = vim.fn.fnamemodify(relative_path, ':t') .. suffix
if available_chars <= 3 then
title = filename
else
if #filename + 5 <= available_chars then
local normalized_path = vim.fs.normalize(relative_path)
local path_parts = vim.split(normalized_path, '[/\\]', { plain = false })
local segments = {}
for _, part in ipairs(path_parts) do
if part ~= '' then table.insert(segments, part) end
end
segments[#segments] = vim.fn.fnamemodify(relative_path, ':t') .. suffix
local segments_to_show = { segments[#segments] }
local current_length = #segments_to_show[1] + 4
for i = #segments - 1, 1, -1 do
local segment = segments[i]
local new_length = current_length + #segment + 1
if new_length <= available_chars then
table.insert(segments_to_show, 1, segment)
current_length = new_length
else
break
end
end
if #segments_to_show == #segments then
title = string.format(' %s ', table.concat(segments_to_show, '/'))
else
title = string.format(' ../%s ', table.concat(segments_to_show, '/'))
end
else
local truncated = filename:sub(1, available_chars - 3) .. '...'
title = string.format(' %s ', truncated)
end
end
end
vim.api.nvim_win_set_config(S.preview_win, {
title = title,
title_pos = 'left',
})
end
function M.update_preview()
if not S.preview_visible then return end
if not P.state.active then return end
local items = S.filtered_items
if #items == 0 or S.cursor > #items then
M.clear_preview()
S.last_preview_file = nil
S.last_preview_location = nil
return
end
local item = items[S.cursor]
if not item then
M.clear_preview()
S.last_preview_file = nil
S.last_preview_location = nil
return
end
local effective_location = S.location
if not effective_location and S.query and S.query ~= '' then
local line_str = S.query:match(':(%d+)$')
if line_str then
local line_num = tonumber(line_str)
if line_num and line_num > 0 then
local l, c = S.query:match(':(%d+):(%d+)$')
if l then
effective_location = { line = tonumber(l), col = tonumber(c) }
else
effective_location = { line = line_num }
end
end
end
end
local is_grep_item = S.mode == 'grep' or S.suggestion_source == 'grep'
if is_grep_item and item.line_number and item.line_number > 0 then
effective_location = { line = item.line_number }
if item.col and item.col > 0 then effective_location.col = item.col + 1 end
effective_location.grep_query = S.query
if S.grep_mode == 'fuzzy' and item.match_ranges then effective_location.fuzzy_match_ranges = item.match_ranges end
end
local location_changed = not vim.deep_equal(S.last_preview_location, effective_location)
if S.last_preview_file == item.relative_path and not location_changed then return end
if S.last_preview_file == item.relative_path and location_changed then
S.last_preview_location = effective_location and vim.deepcopy(effective_location) or nil
preview.state.location = effective_location
if is_grep_item and effective_location and effective_location.line then
M.update_preview_title(item, effective_location)
end
if S.preview_buf and vim.api.nvim_buf_is_valid(S.preview_buf) then
preview.apply_location_highlighting(S.preview_buf)
end
return
end
preview.clear()
S.last_preview_file = item.relative_path
S.last_preview_location = effective_location and vim.deepcopy(effective_location) or nil
M.update_preview_title(item, effective_location)
if S.file_info_buf then
preview.update_file_info_buffer(item, S.file_info_buf, S.cursor, S.preview_win)
if S.file_info_win and vim.api.nvim_win_is_valid(S.file_info_win) then
local rel = item.relative_path or item.path or ''
pcall(vim.api.nvim_win_set_config, S.file_info_win, { title = ' ' .. rel .. ' ', title_pos = 'left' })
end
end
preview.set_preview_window(S.preview_win)
preview.preview(canonicalize_fff_path(item.relative_path), S.preview_buf, effective_location, item.is_binary)
end
function M.clear_preview()
if not P.state.active then return end
if not S.preview_visible then return end
vim.api.nvim_win_set_config(S.preview_win, {
title = ' Preview ',
title_pos = 'left',
})
if S.file_info_buf then
vim.api.nvim_set_option_value('modifiable', true, { buf = S.file_info_buf })
vim.api.nvim_buf_set_lines(S.file_info_buf, 0, -1, false, {})
vim.api.nvim_set_option_value('modifiable', false, { buf = S.file_info_buf })
pcall(vim.api.nvim_buf_clear_namespace, S.file_info_buf, preview.file_info_ns, 0, -1)
end
vim.api.nvim_set_option_value('modifiable', true, { buf = S.preview_buf })
vim.api.nvim_buf_set_lines(S.preview_buf, 0, -1, false, { 'No preview available' })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.preview_buf })
end
return M
+324
View File
@@ -0,0 +1,324 @@
local M = {}
local rust = require('fff.rust')
local list_separator = require('fff.list_separator')
local scrollbar = require('fff.scrollbar')
local list_renderer = require('fff.picker_ui.list_renderer')
local combo_renderer = require('fff.picker_ui.combo_renderer')
local file_picker = require('fff.file_picker')
local layout = require('fff.layout')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
-- Allows renderer functions to call back into the main picker module.
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
-- Convenience alias
local S = picker_ui_state.state
local function get_prompt_position() return layout.resolve_prompt_position(S.config) end
local function shrink_path(path, max_width)
local config = S.config or {}
local strategy = config.layout and config.layout.path_shorten_strategy or 'middle_number'
return rust.shorten_path(path, max_width, strategy)
end
local function format_file_display(item, max_width)
local filename = item.name
if type(filename) ~= 'string' then filename = filename and tostring(filename) or '' end
local dir_path = item.directory or ''
if type(dir_path) ~= 'string' then dir_path = dir_path and tostring(dir_path) or '' end
if dir_path == '' and item.relative_path then
local parent_dir = vim.fn.fnamemodify(item.relative_path, ':h')
if parent_dir ~= '.' and parent_dir ~= '' then dir_path = parent_dir end
end
local filename_width = vim.fn.strdisplaywidth(filename)
local base_width = filename_width + 1
local path_max_width = math.max(max_width - base_width, 0)
if dir_path == '' or path_max_width == 0 then return filename, '' end
local display_path = shrink_path(dir_path, path_max_width)
return filename, display_path
end
--- Adjust scroll for bottom prompt to eliminate gaps.
function M.scroll_to_bottom()
if not S.list_win or not vim.api.nvim_win_is_valid(S.list_win) then return end
local win_height = vim.api.nvim_win_get_height(S.list_win)
local buf_lines = vim.api.nvim_buf_line_count(S.list_buf)
vim.api.nvim_win_call(S.list_win, function()
local view = vim.fn.winsaveview()
local bottom_topline = math.max(1, buf_lines - win_height + 1)
local cursor_line = vim.api.nvim_win_get_cursor(S.list_win)[1]
if cursor_line >= bottom_topline then
view.topline = bottom_topline
elseif cursor_line < view.topline then
view.topline = math.max(1, cursor_line - 1)
elseif cursor_line >= view.topline + win_height then
view.topline = bottom_topline
end
vim.fn.winrestview(view)
end)
end
--- Render the grep empty state.
local function render_grep_empty_state(ctx)
list_separator.hide()
local config = ctx.config
local win_width = ctx.win_width
local win_height = ctx.win_height
local prompt_position = ctx.prompt_position
local content = {}
local hl_cmds = {}
table.insert(content, '')
table.insert(content, ' Start typing to search file contents...')
table.insert(content, '')
table.insert(content, ' Tips:')
table.insert(content, ' "pattern *.rs" search only in Rust files')
table.insert(content, ' "pattern /src/" limit search to src/ directory')
table.insert(content, ' "!test pattern" exclude test files')
table.insert(content, '')
if prompt_position == 'bottom' then
local empty_needed = math.max(0, win_height - #content)
for _ = 1, empty_needed do
table.insert(content, 1, string.rep(' ', win_width + 5))
for _, h in ipairs(hl_cmds) do
h.row = h.row + 1
end
end
end
vim.api.nvim_set_option_value('modifiable', true, { buf = S.list_buf })
vim.api.nvim_buf_set_lines(S.list_buf, 0, -1, false, content)
vim.api.nvim_set_option_value('modifiable', false, { buf = S.list_buf })
vim.api.nvim_buf_clear_namespace(S.list_buf, S.ns_id, 0, -1)
if prompt_position == 'bottom' then M.scroll_to_bottom() end
for _, h in ipairs(hl_cmds) do
pcall(
vim.api.nvim_buf_set_extmark,
S.list_buf,
S.ns_id,
h.row,
h.col_start,
{ end_col = h.col_end, hl_group = h.hl }
)
end
for i = 0, #content - 1 do
local line = content[i + 1]
if
line and (line:match('^%s+Start typing') or line:match('^%s+Tips') or line:match('^%s+"') or line:match('^%s+!'))
then
pcall(
vim.api.nvim_buf_set_extmark,
S.list_buf,
S.ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = 'Comment' }
)
end
if line and (line:match('^%s+[╭╰│]') or line:match('[╮╯│]%s*$')) then
pcall(
vim.api.nvim_buf_set_extmark,
S.list_buf,
S.ns_id,
i,
0,
{ end_row = i + 1, end_col = 0, hl_group = config.hl.border or 'FloatBorder' }
)
end
end
end
--- Build rendering context with all necessary data.
local function build_render_context()
local config = S.config or {}
local items = S.filtered_items
local win_height = vim.api.nvim_win_get_height(S.list_win)
local win_width = vim.api.nvim_win_get_width(S.list_win)
local prompt_position = get_prompt_position()
local win_info = vim.fn.getwininfo(S.list_win)[1]
local text_offset = win_info and win_info.textoff or 2
local text_width = win_width - text_offset
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
local separator = nil
local combo_info = nil
if S.mode ~= 'grep' and not S.suggestion_source then
combo_info = combo_renderer.detect(
items,
file_picker,
combo_boost_score_multiplier,
S.next_search_force_combo_boost or config.history.min_combo_count == 0
)
end
S.next_search_force_combo_boost = false
if combo_info and S.combo_visible then
separator = {
idx = combo_info.idx,
text = combo_info.text,
text_hl = config.hl.combo_header,
border_hl = config.hl.border,
}
S.combo_initial_cursor = combo_info.idx
end
local display_start = 1
local display_end = #items
if separator and (display_end - display_start + 1) >= win_height then
if separator.idx == display_start then
display_end = display_end - 1
else
display_start = display_start + 1
end
end
local iter_start, iter_end, iter_step
if prompt_position == 'bottom' then
iter_start, iter_end, iter_step = display_end, display_start, -1
else
iter_start, iter_end, iter_step = display_start, display_end, 1
end
return {
config = config,
items = items,
cursor = S.cursor,
win_height = win_height,
win_width = win_width,
max_path_width = text_width,
debug_enabled = config and config.debug and config.debug.show_scores,
prompt_position = prompt_position,
separator = separator,
combo_info = combo_info,
display_start = display_start,
display_end = display_end,
iter_start = iter_start,
iter_end = iter_end,
iter_step = iter_step,
renderer = S.suggestion_source and P.get_suggestion_renderer() or S.renderer,
query = S.query,
selected_files = S.selected_files,
selected_items = S.selected_items,
mode = S.mode,
format_file_display = format_file_display,
suggestion_source = S.suggestion_source,
}
end
local function finalize_render(separator_line, ctx)
if ctx.separator and separator_line then
local arrow = ctx.prompt_position == 'bottom' and '' or ''
local list_cfg = vim.api.nvim_win_get_config(S.list_win)
local screen_row = list_cfg.row + separator_line
list_separator.update({
list_win = S.list_win,
row = screen_row,
text = arrow .. ' ' .. ctx.separator.text,
text_hl = ctx.separator.text_hl,
border_hl = ctx.separator.border_hl,
})
else
list_separator.hide()
end
if ctx.mode ~= 'grep' and not ctx.suggestion_source then
scrollbar.render(S.layout, ctx.config, S.list_win, S.pagination, ctx.prompt_position)
end
end
function M.render_list()
if not P.state.active then return end
local ctx = build_render_context()
if S.mode == 'grep' and #ctx.items == 0 then
S.line_to_item = {}
S.item_to_lines = {}
S.last_render_ctx = nil
render_grep_empty_state(ctx)
return
end
local item_to_lines, separator_line = list_renderer.render(ctx, S.list_buf, S.list_win, S.ns_id)
S.item_to_lines = item_to_lines
S.last_render_ctx = ctx
local line_to_item = {}
for item_idx, mapping in pairs(item_to_lines) do
for line = mapping.first, mapping.last do
line_to_item[line] = item_idx
end
end
S.line_to_item = line_to_item
if ctx.prompt_position == 'bottom' then M.scroll_to_bottom() end
finalize_render(separator_line, ctx)
end
local function rerender_cursor_rows(old_cursor, new_cursor)
if S.suggestion_source then return false end
local ctx = S.last_render_ctx
if not ctx or ctx.items ~= S.filtered_items then return false end
if ctx.renderer and not ctx.renderer.supports_cursor_rerender then return false end
local old_lines = S.item_to_lines[old_cursor]
local new_lines = S.item_to_lines[new_cursor]
if not old_lines or not new_lines then return false end
local renderer = ctx.renderer or require('fff.picker_ui.file_renderer')
local entries = {
{ item_idx = old_cursor, lines = old_lines },
{ item_idx = new_cursor, lines = new_lines },
}
for _, entry in ipairs(entries) do
entry.item = ctx.items[entry.item_idx]
if not entry.item then return false end
local line_idx = entry.lines.last
entry.line = vim.api.nvim_buf_get_lines(S.list_buf, line_idx - 1, line_idx, false)[1]
if not entry.line then return false end
end
ctx.cursor = new_cursor
for _, entry in ipairs(entries) do
vim.api.nvim_buf_clear_namespace(S.list_buf, S.ns_id, entry.lines.first - 1, entry.lines.last)
renderer.apply_highlights(entry.item, ctx, entry.item_idx, S.list_buf, S.ns_id, entry.lines.last, entry.line)
end
vim.api.nvim_win_set_cursor(S.list_win, { new_lines.last, 0 })
return true
end
function M.render_after_cursor_move(old_cursor)
if old_cursor == S.cursor then return false end
if old_cursor and rerender_cursor_rows(old_cursor, S.cursor) then return true end
M.render_list()
return true
end
return M
+367
View File
@@ -0,0 +1,367 @@
local M = {}
local file_picker = require('fff.file_picker')
local grep = require('fff.picker_ui.grep_renderer')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
-- Allows search_manager functions to call back into the main picker module.
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
-- Convenience alias
local S = picker_ui_state.state
function M.update_results() M.update_results_sync() end
function M.update_results_sync()
if not P.state.active then return end
if not S.current_file_cache then
local current_buf = vim.api.nvim_get_current_buf()
if current_buf and vim.api.nvim_buf_is_valid(current_buf) then
local current_file = vim.api.nvim_buf_get_name(current_buf)
S.current_file_cache = (current_file ~= '' and vim.fn.filereadable(current_file) == 1) and current_file or nil
end
end
local page_size
if S.list_win and vim.api.nvim_win_is_valid(S.list_win) then
page_size = vim.api.nvim_win_get_height(S.list_win)
else
page_size = S.config.max_results or 100
end
S.pagination.page_size = page_size
S.pagination.page_index = 0
S.combo_visible = true
S.combo_initial_cursor = 1
local min_combo_override = nil
if S.next_search_force_combo_boost then min_combo_override = 0 end
local results
if S.mode == 'grep' then
S.grep_regex_fallback_error = nil
if S.query == '' then
results = {}
S.pagination.total_matched = 0
S.pagination.grep_file_offsets = {}
S.pagination.grep_next_file_offset = 0
else
local grep_result = grep.search(S.query, 0, page_size, S.grep_config, S.grep_mode)
results = grep_result.items or {}
S.pagination.total_matched = grep_result.total_matched or 0
S.pagination.grep_file_offsets = { 0 }
S.pagination.grep_next_file_offset = grep_result.next_file_offset or 0
S.grep_regex_fallback_error = grep_result.regex_fallback_error or nil
if grep_result.next_file_offset and grep_result.next_file_offset > 0 then
S.pagination.grep_file_offsets[2] = grep_result.next_file_offset
end
end
S.location = nil
else
results = file_picker.search_files_paginated(
S.query,
S.current_file_cache,
S.config.max_threads,
min_combo_override,
0,
page_size
)
S.location = file_picker.get_search_location()
local metadata = file_picker.get_search_metadata()
S.pagination.total_matched = metadata.total_matched
end
S.items = results
S.filtered_items = results
S.suggestion_items = nil
S.suggestion_source = nil
if #results == 0 and S.query ~= '' then
if S.mode == 'grep' then
local suggestion_results =
file_picker.search_files_paginated(S.query, S.current_file_cache, S.config.max_threads, nil, 0, page_size)
if suggestion_results and #suggestion_results > 0 then
S.suggestion_items = suggestion_results
S.suggestion_source = 'files'
end
else
local grep_result = grep.search(S.query, 0, page_size, S.grep_config, 'plain')
local grep_items = grep_result and grep_result.items or {}
if #grep_items > 0 then
S.suggestion_items = grep_items
S.suggestion_source = 'grep'
end
end
end
if S.suggestion_items and #S.suggestion_items > 0 then S.filtered_items = S.suggestion_items end
-- On resume, restore the saved cursor onto the fresh results (clamped, since
-- the result set may have changed since close). Otherwise reset to the top.
if S.pending_restore_cursor then
S.cursor = math.max(1, math.min(S.pending_restore_cursor, #S.filtered_items))
S.pending_restore_cursor = nil
else
S.cursor = 1
end
P.render_debounced()
end
function M.load_page_at_index(new_page_index, adjust_cursor_fn)
local ok, err, results
local page_size = S.pagination.page_size
if page_size == 0 then return false end
if S.mode ~= 'grep' then
local total = S.pagination.total_matched
if total == 0 then return false end
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
new_page_index = math.max(0, math.min(new_page_index, max_page_index))
end
if S.mode == 'grep' then
local file_offset = S.pagination.grep_file_offsets[new_page_index + 1]
if file_offset == nil then return false end
ok, results = pcall(grep.search, S.query, file_offset, page_size, S.grep_config, S.grep_mode)
if ok and results then
local grep_result = results
results = grep_result.items or {}
S.pagination.total_matched = grep_result.total_matched or 0
S.pagination.grep_next_file_offset = grep_result.next_file_offset or 0
S.grep_regex_fallback_error = grep_result.regex_fallback_error or nil
if grep_result.next_file_offset and grep_result.next_file_offset > 0 then
S.pagination.grep_file_offsets[new_page_index + 2] = grep_result.next_file_offset
end
end
else
ok, results = pcall(
file_picker.search_files_paginated,
S.query,
S.current_file_cache,
S.config.max_threads,
nil,
new_page_index,
page_size
)
end
if not ok then
vim.notify('Error in paginated search: ' .. tostring(results), vim.log.levels.ERROR)
return false
end
if #results == 0 then return false end
if S.mode ~= 'grep' then
local metadata = file_picker.get_search_metadata()
S.pagination.total_matched = metadata.total_matched
end
S.items = results
S.filtered_items = results
S.pagination.page_index = new_page_index
if adjust_cursor_fn then
local cursor_ok, cursor_err = pcall(adjust_cursor_fn, #results)
if not cursor_ok then
vim.notify('Error in cursor adjustment: ' .. tostring(cursor_err), vim.log.levels.ERROR)
return false
end
end
ok, err = pcall(P.render_list)
if not ok then
vim.notify('Error in render_list: ' .. tostring(err), vim.log.levels.ERROR)
return false
end
ok, err = pcall(P.update_preview)
if not ok then
vim.notify('Error in update_preview: ' .. tostring(err), vim.log.levels.ERROR)
return false
end
ok, err = pcall(P.update_status)
if not ok then
vim.notify('Error in update_status: ' .. tostring(err), vim.log.levels.ERROR)
return false
end
return true
end
function M.load_next_page()
local page_size = S.pagination.page_size
local current_page = S.pagination.page_index
if page_size == 0 then return false end
if S.mode == 'grep' then
if S.pagination.grep_next_file_offset == 0 then return false end
return M.load_page_at_index(current_page + 1, function() S.cursor = 1 end)
end
local total = S.pagination.total_matched
if total == 0 then return false end
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
if current_page >= max_page_index then return false end
return M.load_page_at_index(current_page + 1, function() S.cursor = 1 end)
end
function M.load_previous_page()
if S.pagination.page_index == 0 then return false end
return M.load_page_at_index(S.pagination.page_index - 1, function(result_count) S.cursor = result_count end)
end
function M.on_input_change()
if not P.state.active then return end
local lines = vim.api.nvim_buf_get_lines(S.input_buf, 0, -1, false)
local prompt_len = #S.config.prompt
local query = ''
if #lines > 1 then
local all_text = table.concat(lines, '')
if all_text:sub(1, prompt_len) == S.config.prompt then
query = all_text:sub(prompt_len + 1)
else
query = all_text
end
query = query:gsub('\r', ''):match('^%s*(.-)%s*$') or ''
vim.api.nvim_set_option_value('modifiable', true, { buf = S.input_buf })
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt .. query })
vim.schedule(function()
if P.state.active and S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_cursor(S.input_win, { 1, prompt_len + #query })
end
end)
else
local full_line = lines[1] or ''
if full_line:sub(1, prompt_len) == S.config.prompt then query = full_line:sub(prompt_len + 1) end
end
S.query = query
M.update_results_sync()
end
function M.cycle_grep_modes()
if not P.state.active or S.mode ~= 'grep' then return end
---@diagnostic disable-next-line: undefined-field
local modes = (S.grep_config and S.grep_config.modes) or S.config.grep.modes or { 'plain', 'regex', 'fuzzy' }
if #modes <= 1 then return end
local current_idx = 1
for i, m in ipairs(modes) do
if m == S.grep_mode then
current_idx = i
break
end
end
S.grep_mode = modes[(current_idx % #modes) + 1]
if S.grep_mode ~= 'regex' then S.grep_regex_fallback_error = nil end
S.last_status_info = nil
P.update_status()
if S.query ~= '' then M.update_results_sync() end
end
function M.recall_query_from_history()
if not P.state.active then return end
if S.history_offset == nil then
S.history_offset = 0
else
S.history_offset = S.history_offset + 1
end
local fuzzy = require('fff.core').ensure_initialized()
local history_fn = S.mode == 'grep' and fuzzy.get_historical_grep_query or fuzzy.get_historical_query
local ok, query = pcall(history_fn, S.history_offset)
if not ok or not query then
S.history_offset = 0
ok, query = pcall(history_fn, 0)
if not ok or not query then
vim.notify('No query history available', vim.log.levels.INFO)
S.history_offset = nil
return
end
end
if S.mode ~= 'grep' then S.next_search_force_combo_boost = true end
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt .. query })
vim.schedule(function()
if P.state.active and S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_cursor(S.input_win, { 1, #S.config.prompt + #query })
end
end)
end
function M.cycle_forward_query()
if not P.state.active then return end
if S.history_offset == nil then
-- At top of stack (fresh open or resume with pre-filled input).
-- Clear input to return to a clean slate.
S.history_offset = nil
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt })
return
elseif S.history_offset == 0 then
-- At the most recent history entry, go back to present
S.history_offset = nil
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt })
return
else
S.history_offset = S.history_offset - 1
end
local fuzzy = require('fff.core').ensure_initialized()
local history_fn = S.mode == 'grep' and fuzzy.get_historical_grep_query or fuzzy.get_historical_query
local ok, query = pcall(history_fn, S.history_offset)
if not ok or not query then
S.history_offset = nil
return
end
if S.mode ~= 'grep' then S.next_search_force_combo_boost = true end
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { S.config.prompt .. query })
vim.schedule(function()
if P.state.active and S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_cursor(S.input_win, { 1, #S.config.prompt + #query })
end
end)
end
function M.get_suggestion_renderer()
if S.suggestion_source == 'grep' then
return require('fff.picker_ui.grep_renderer')
else
return require('fff.picker_ui.file_renderer')
end
end
return M
+500
View File
@@ -0,0 +1,500 @@
local M = {}
local conf = require('fff.conf')
local layout = require('fff.layout')
local preview = require('fff.file_picker.preview')
local list_separator = require('fff.list_separator')
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
-- Parent module reference (set by picker_ui.lua during initialization).
-- Allows ui_creator functions to call back into the main picker module.
---@type table
local P = nil
function M.init(parent_module) P = parent_module end
-- Convenience alias
local S = picker_ui_state.state
local function resolve_winhl(kind)
local hl = S.config.hl
local winhl = hl.winhl
local default_winhl = string.format('Normal:%s,FloatBorder:%s,FloatTitle:%s', hl.normal, hl.border, hl.title)
if winhl == nil then return default_winhl end
if type(winhl) == 'string' then return winhl end
if type(winhl) == 'table' then return winhl[kind] or default_winhl end
return default_winhl
end
local function open_preview(win_cfg)
if not win_cfg then return end
if S.preview_win and vim.api.nvim_win_is_valid(S.preview_win) then return end
if not (S.preview_buf and vim.api.nvim_buf_is_valid(S.preview_buf)) then
S.preview_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.preview_buf })
vim.api.nvim_buf_set_name(S.preview_buf, 'fffile preview')
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = S.preview_buf })
vim.api.nvim_set_option_value('filetype', 'fff_preview', { buf = S.preview_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.preview_buf })
end
S.preview_win = vim.api.nvim_open_win(S.preview_buf, false, win_cfg)
local win_hl = resolve_winhl('preview')
vim.api.nvim_set_option_value('wrap', false, { win = S.preview_win })
vim.api.nvim_set_option_value('cursorline', false, { win = S.preview_win })
vim.api.nvim_set_option_value('cursorlineopt', vim.o.cursorlineopt, { win = S.preview_win })
vim.api.nvim_set_option_value(
'number',
S.mode == 'grep' or (conf.get().preview and conf.get().preview.line_numbers or false),
{ win = S.preview_win }
)
vim.api.nvim_set_option_value('relativenumber', false, { win = S.preview_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = S.preview_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = S.preview_win })
vim.api.nvim_set_option_value('winhighlight', win_hl, { win = S.preview_win })
preview.set_preview_window(S.preview_win)
end
local function close_preview()
if S.preview_win and vim.api.nvim_win_is_valid(S.preview_win) then vim.api.nvim_win_close(S.preview_win, true) end
S.preview_win = nil
if S.preview_buf and vim.api.nvim_buf_is_valid(S.preview_buf) then
preview.clear_buffer(S.preview_buf)
vim.api.nvim_buf_delete(S.preview_buf, { force = true })
end
S.preview_buf = nil
S.last_preview_file = nil
S.last_preview_location = nil
end
local function set_keymap(mode, keys, handler, opts)
local normalized_keys
if type(keys) == 'string' then
normalized_keys = { keys }
elseif type(keys) == 'table' then
normalized_keys = keys
else
normalized_keys = {}
end
for _, key in ipairs(normalized_keys) do
vim.keymap.set(mode, key, handler, opts)
end
end
local function handle_mouse_click_or_fallback(action, fallback)
local pos = vim.fn.getmousepos()
if P.state.active and pos.winid == S.list_win then
local item_idx = S.line_to_item[pos.line]
if not item_idx then return '' end
vim.schedule(function()
if not P.state.active then return end
if not S.filtered_items[item_idx] then return end
if S.cursor ~= item_idx then
S.cursor = item_idx
P.render_list()
if S.mode == 'grep' or S.suggestion_source == 'grep' then
P.update_preview_smart()
else
P.update_preview()
end
P.update_status()
end
if action then P.select(action) end
end)
return ''
end
return fallback
end
local function move_list_cursor(direction)
if not P.state.active then return end
local items = S.filtered_items
if #items == 0 then return end
local wrap_around = S.config and S.config.wrap_around or false
local new_cursor = S.cursor + direction
if wrap_around then
if new_cursor < 1 then
new_cursor = #items
elseif new_cursor > #items then
new_cursor = 1
end
else
new_cursor = math.max(1, math.min(new_cursor, #items))
end
if new_cursor ~= S.cursor then
S.cursor = new_cursor
P.render_list()
if S.mode == 'grep' or S.suggestion_source == 'grep' then
P.update_preview_smart()
else
P.update_preview()
end
P.update_status()
end
end
function M.create_ui()
local config = S.config
if not config then return false end
-- Prompt editing should behave consistently even if the user has :set paste.
S.restore_paste = (function()
if not vim.o.paste then return false end
vim.o.paste = false
return true
end)()
if not S.ns_id then
S.ns_id = vim.api.nvim_create_namespace('fff_picker_status')
list_separator.init(S.ns_id)
end
local computed_layout = layout.compute(config, conf.preview_enabled(config))
local win_configs = computed_layout.win_configs
local debug_enabled_in_preview = computed_layout.debug_enabled
S.layout = computed_layout.layout
S.preview_visible = computed_layout.preview_visible
S.input_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.input_buf })
S.list_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.list_buf })
if debug_enabled_in_preview then
S.file_info_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = S.file_info_buf })
else
S.file_info_buf = nil
end
S.list_win = vim.api.nvim_open_win(S.list_buf, false, win_configs.list)
if debug_enabled_in_preview and win_configs.file_info then
S.file_info_win = vim.api.nvim_open_win(S.file_info_buf, false, win_configs.file_info)
else
S.file_info_win = nil
end
if S.preview_visible then open_preview(win_configs.preview) end
S.input_win = vim.api.nvim_open_win(S.input_buf, false, win_configs.input)
M.setup_buffers()
M.setup_windows()
M.setup_keymaps()
vim.api.nvim_set_current_win(S.input_win)
P.update_results_sync()
P.clear_preview()
P.update_status()
return true
end
function M.setup_buffers()
vim.api.nvim_buf_set_name(S.input_buf, 'fffile search')
vim.api.nvim_buf_set_name(S.list_buf, 'fffiles list')
vim.api.nvim_set_option_value('buftype', 'prompt', { buf = S.input_buf })
vim.api.nvim_set_option_value('filetype', 'fff_input', { buf = S.input_buf })
vim.fn.prompt_setprompt(S.input_buf, S.config.prompt)
-- Changing the contents of the input buffer will trigger Neovim to guess the language
-- in order to provide syntax highlighting. This makes sure that it's always off.
vim.api.nvim_create_autocmd('Syntax', {
buffer = S.input_buf,
callback = function() vim.api.nvim_set_option_value('syntax', '', { buf = S.input_buf }) end,
})
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = S.list_buf })
vim.api.nvim_set_option_value('filetype', 'fff_list', { buf = S.list_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.list_buf })
if S.file_info_buf then
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('filetype', 'fff_file_info', { buf = S.file_info_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = S.file_info_buf })
end
end
function M.setup_windows()
local prompt_win_hl = resolve_winhl('prompt')
local list_win_hl = resolve_winhl('list')
local file_info_win_hl = resolve_winhl('file_info')
vim.api.nvim_set_option_value('wrap', false, { win = S.input_win })
vim.api.nvim_set_option_value('cursorline', false, { win = S.input_win })
vim.api.nvim_set_option_value('number', false, { win = S.input_win })
vim.api.nvim_set_option_value('relativenumber', false, { win = S.input_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = S.input_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = S.input_win })
vim.api.nvim_set_option_value('winhighlight', prompt_win_hl, { win = S.input_win })
vim.api.nvim_set_option_value('wrap', false, { win = S.list_win })
vim.api.nvim_set_option_value('cursorline', false, { win = S.list_win })
vim.api.nvim_set_option_value('number', false, { win = S.list_win })
vim.api.nvim_set_option_value('relativenumber', false, { win = S.list_win })
vim.api.nvim_set_option_value('signcolumn', 'yes:1', { win = S.list_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = S.list_win })
vim.api.nvim_set_option_value('winhighlight', list_win_hl, { win = S.list_win })
if S.file_info_win and vim.api.nvim_win_is_valid(S.file_info_win) then
vim.api.nvim_set_option_value('wrap', false, { win = S.file_info_win })
vim.api.nvim_set_option_value('cursorline', false, { win = S.file_info_win })
vim.api.nvim_set_option_value('number', false, { win = S.file_info_win })
vim.api.nvim_set_option_value('relativenumber', false, { win = S.file_info_win })
vim.api.nvim_set_option_value('signcolumn', 'no', { win = S.file_info_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = S.file_info_win })
vim.api.nvim_set_option_value('winhighlight', file_info_win_hl, { win = S.file_info_win })
end
local picker_group = vim.api.nvim_create_augroup('fff_picker_focus', { clear = true })
local function is_picker_window(win)
if not win or not vim.api.nvim_win_is_valid(win) then return false end
local picker_windows = { S.input_win, S.list_win }
if S.preview_win then table.insert(picker_windows, S.preview_win) end
if S.file_info_win then table.insert(picker_windows, S.file_info_win) end
for _, picker_win in ipairs(picker_windows) do
if picker_win and vim.api.nvim_win_is_valid(picker_win) and win == picker_win then return true end
end
return false
end
vim.api.nvim_create_autocmd('WinLeave', {
group = picker_group,
callback = function()
if not P.state.active then return end
local leaving_win = vim.api.nvim_get_current_win()
if not is_picker_window(leaving_win) then return end
vim.schedule(function()
if not P.state.active then return end
local new_win = vim.api.nvim_get_current_win()
if not is_picker_window(new_win) then P.close() end
end)
end,
desc = 'Close picker when focus leaves picker windows',
})
vim.api.nvim_create_autocmd('VimResized', {
group = picker_group,
callback = function()
if not P.state.active then return end
vim.schedule(function()
if not P.state.active then return end
P.relayout()
end)
end,
desc = 'Re-layout picker on terminal resize',
})
end
function M.setup_keymaps()
local keymaps = S.config.keymaps
local input_opts = { buffer = S.input_buf, noremap = true, silent = true }
local list_opts = { buffer = S.list_buf, noremap = true, silent = true }
vim.keymap.set('i', '<C-w>', function()
local col = vim.fn.col('.') - 1
local line = vim.fn.getline('.')
local prompt_len = #S.config.prompt
if col <= prompt_len then return '' end
local text_part = line:sub(prompt_len + 1, col)
local after_cursor = line:sub(col + 1)
local new_text = text_part:gsub('%S*%s*$', '')
local new_line = S.config.prompt .. new_text .. after_cursor
local new_col = prompt_len + #new_text
vim.fn.setline('.', new_line)
vim.fn.cursor(vim.fn.line('.'), new_col + 1)
return ''
end, input_opts)
set_keymap({ 'n', 'i' }, keymaps.move_up, P.move_up, input_opts)
set_keymap({ 'n', 'i' }, keymaps.move_down, P.move_down, input_opts)
set_keymap('i', keymaps.cycle_previous_query, P.recall_query_from_history, input_opts)
set_keymap('i', keymaps.cycle_forward_query, P.cycle_forward_query, input_opts)
set_keymap('n', 'j', P.move_down, input_opts)
set_keymap('n', 'k', P.move_up, input_opts)
set_keymap('n', 'q', P.close, input_opts)
set_keymap('n', keymaps.focus_list, M.focus_list_win, input_opts)
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, input_opts)
if keymaps.grep_jump_to_next_file then
set_keymap({ 'i', 'n' }, keymaps.grep_jump_to_next_file, function() P.grep_jump_to_next_file() end, input_opts)
end
if keymaps.grep_jump_to_prev_file then
set_keymap({ 'i', 'n' }, keymaps.grep_jump_to_prev_file, function() P.grep_jump_to_prev_file() end, input_opts)
end
if S.config.prompt_vim_mode then
set_keymap('n', keymaps.close, P.close, input_opts)
set_keymap('i', '<C-c>', P.close, input_opts)
-- cc/S clear the whole line, wiping the prompt buffer's prompt and leaving
-- a stray icon behind. Reset to just the prompt and re-enter insert instead.
local function clear_query_line()
local prompt = S.config.prompt
vim.api.nvim_set_option_value('modifiable', true, { buf = S.input_buf })
vim.api.nvim_buf_set_lines(S.input_buf, 0, -1, false, { prompt })
vim.schedule(function()
if S.input_win and vim.api.nvim_win_is_valid(S.input_win) then
vim.api.nvim_win_set_cursor(S.input_win, { 1, #prompt })
vim.cmd('startinsert!')
end
end)
end
-- remap=true so existing user mappings of cc/S still resolve
local clear_opts = vim.tbl_extend('force', input_opts, { noremap = false, remap = true })
set_keymap('n', 'cc', clear_query_line, clear_opts)
set_keymap('n', 'S', clear_query_line, clear_opts)
else
set_keymap({ 'i', 'n' }, keymaps.close, P.close, input_opts)
end
set_keymap({ 'i', 'n' }, keymaps.select, P.select, input_opts)
set_keymap({ 'i', 'n' }, keymaps.select_split, function() P.select('split') end, input_opts)
set_keymap({ 'i', 'n' }, keymaps.select_vsplit, function() P.select('vsplit') end, input_opts)
set_keymap({ 'i', 'n' }, keymaps.select_tab, function() P.select('tab') end, input_opts)
set_keymap({ 'i', 'n' }, keymaps.preview_scroll_up, P.scroll_preview_up, input_opts)
set_keymap({ 'i', 'n' }, keymaps.preview_scroll_down, P.scroll_preview_down, input_opts)
set_keymap({ 'i', 'n' }, keymaps.toggle_debug, P.toggle_debug, input_opts)
set_keymap({ 'i', 'n' }, keymaps.toggle_select, P.toggle_select, input_opts)
set_keymap({ 'i', 'n' }, keymaps.send_to_quickfix, P.send_to_quickfix, input_opts)
set_keymap({ 'i', 'n' }, keymaps.cycle_grep_modes, P.cycle_grep_modes, input_opts)
local input_mouse_opts = vim.tbl_extend('force', input_opts, { expr = true, replace_keycodes = true })
set_keymap(
{ 'i', 'n' },
'<LeftMouse>',
function() return handle_mouse_click_or_fallback(nil, '<LeftMouse>') end,
input_mouse_opts
)
set_keymap(
{ 'i', 'n' },
'<2-LeftMouse>',
function() return handle_mouse_click_or_fallback('edit', '<2-LeftMouse>') end,
input_mouse_opts
)
-- List buffer
set_keymap('n', keymaps.close, P.close, list_opts)
set_keymap('n', 'q', P.close, list_opts)
set_keymap('n', 'j', function() move_list_cursor(1) end, list_opts)
set_keymap('n', 'k', function() move_list_cursor(-1) end, list_opts)
set_keymap('n', 'i', M.focus_input_win, list_opts)
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, list_opts)
set_keymap('n', keymaps.select, P.select, list_opts)
set_keymap('n', keymaps.select_split, function() P.select('split') end, list_opts)
set_keymap('n', keymaps.select_vsplit, function() P.select('vsplit') end, list_opts)
set_keymap('n', keymaps.select_tab, function() P.select('tab') end, list_opts)
set_keymap('n', keymaps.preview_scroll_up, P.scroll_preview_up, list_opts)
set_keymap('n', keymaps.preview_scroll_down, P.scroll_preview_down, list_opts)
set_keymap('n', keymaps.toggle_debug, P.toggle_debug, list_opts)
set_keymap('n', keymaps.toggle_select, P.toggle_select, list_opts)
set_keymap('n', keymaps.send_to_quickfix, P.send_to_quickfix, list_opts)
local list_mouse_opts = vim.tbl_extend('force', list_opts, { expr = true, replace_keycodes = true })
set_keymap(
'n',
'<LeftMouse>',
function() return handle_mouse_click_or_fallback(nil, '<LeftMouse>') end,
list_mouse_opts
)
set_keymap(
'n',
'<2-LeftMouse>',
function() return handle_mouse_click_or_fallback('edit', '<2-LeftMouse>') end,
list_mouse_opts
)
-- Preview buffer
if S.preview_buf then
local preview_opts = { buffer = S.preview_buf, noremap = true, silent = true }
set_keymap('n', keymaps.close, P.close, preview_opts)
set_keymap('n', 'q', P.close, preview_opts)
set_keymap('n', 'i', M.focus_input_win, preview_opts)
set_keymap('n', keymaps.focus_list, M.focus_list_win, preview_opts)
set_keymap('n', keymaps.select, P.select, preview_opts)
set_keymap('n', keymaps.select_split, function() P.select('split') end, preview_opts)
set_keymap('n', keymaps.select_vsplit, function() P.select('vsplit') end, preview_opts)
set_keymap('n', keymaps.select_tab, function() P.select('tab') end, preview_opts)
set_keymap('n', keymaps.toggle_debug, P.toggle_debug, preview_opts)
set_keymap('n', keymaps.toggle_select, P.toggle_select, preview_opts)
set_keymap('n', keymaps.send_to_quickfix, P.send_to_quickfix, preview_opts)
end
vim.api.nvim_buf_attach(S.input_buf, false, {
on_lines = function()
vim.schedule(function() P.on_input_change() end)
end,
})
if S.config.prompt_vim_mode then
vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
buffer = S.input_buf,
callback = function()
local prompt_len = #S.config.prompt
if vim.fn.col('.') <= prompt_len then vim.fn.cursor(vim.fn.line('.'), prompt_len + 1) end
end,
})
end
end
function M.focus_list_win()
if not P.state.active then return end
if not S.list_win or not vim.api.nvim_win_is_valid(S.list_win) then return end
vim.cmd('stopinsert')
vim.api.nvim_set_current_win(S.list_win)
end
function M.focus_preview_win()
if not P.state.active then return end
if not S.preview_win or not vim.api.nvim_win_is_valid(S.preview_win) then return end
vim.cmd('stopinsert')
vim.api.nvim_set_current_win(S.preview_win)
end
function M.focus_input_win()
if not P.state.active then return end
if not S.input_win or not vim.api.nvim_win_is_valid(S.input_win) then return end
vim.api.nvim_set_current_win(S.input_win)
vim.api.nvim_win_call(S.input_win, function() vim.cmd('startinsert!') end)
end
-- Expose open/close preview for relayout in the main module
M.open_preview = open_preview
M.close_preview = close_preview
return M
+97
View File
@@ -0,0 +1,97 @@
local M = {}
local picker_ui_state = require('fff.picker_ui.picker_ui_state')
local layout_manager = require('fff.picker_ui.layout_manager')
local utils = require('fff.utils')
local canonicalize_fff_path = utils.canonicalize_fff_path
--- Build quickfix list from selections and open it.
--- Handles close, setqflist, copen, and notification.
function M.send_to_quickfix()
local S = picker_ui_state.state
if not S.active then return end
local qf_list = {}
if S.mode == 'grep' then
local has_selections = next(S.selected_items) ~= nil
if has_selections then
for _, item in pairs(S.selected_items) do
local abs = canonicalize_fff_path(item.relative_path)
if abs then
table.insert(qf_list, {
filename = abs,
lnum = item.line_number or 1,
col = (item.col or 0) + 1,
text = item.line_content or vim.fn.fnamemodify(abs, ':.'),
})
end
end
else
local grep = require('fff.picker_ui.grep_renderer')
local exhaustive_config = vim.tbl_extend('force', S.grep_config or {}, { max_matches_per_file = 0 })
local exhaustive = grep.search(S.query, 0, 10000, exhaustive_config, S.grep_mode)
local all_items = exhaustive and exhaustive.items or {}
if #all_items == 0 then
vim.notify('No matches to send to quickfix', vim.log.levels.WARN)
return
end
for _, item in ipairs(all_items) do
local abs = canonicalize_fff_path(item.relative_path)
if abs then
table.insert(qf_list, {
filename = abs,
lnum = item.line_number or 1,
col = (item.col or 0) + 1,
text = item.line_content or vim.fn.fnamemodify(abs, ':.'),
})
end
end
end
else
local paths = {}
if next(S.selected_files) then
for relative_path, _ in pairs(S.selected_files) do
table.insert(paths, canonicalize_fff_path(relative_path))
end
else
for _, item in ipairs(S.filtered_items) do
local abs = canonicalize_fff_path(item.relative_path)
if abs then table.insert(paths, abs) end
end
end
if #paths == 0 then
vim.notify('No files to send to quickfix', vim.log.levels.WARN)
return
end
for _, path in ipairs(paths) do
table.insert(qf_list, {
filename = path,
lnum = 1,
col = 1,
text = vim.fn.fnamemodify(path, ':.'),
})
end
end
local is_grep = S.mode == 'grep'
-- Close picker, populate quickfix, open it
layout_manager.close()
vim.fn.setqflist(qf_list)
vim.cmd('copen')
local count = #qf_list
local unit = is_grep and (count == 1 and 'match' or 'matches') or (count == 1 and 'file' or 'files')
vim.notify(string.format('Added %d %s to quickfix list', count, unit), vim.log.levels.INFO)
end
return M