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