diff --git a/EXPERIMENT_EXTENSION_PLUGIN.md b/EXPERIMENT_EXTENSION_PLUGIN.md index 595868fc..157a6685 100644 --- a/EXPERIMENT_EXTENSION_PLUGIN.md +++ b/EXPERIMENT_EXTENSION_PLUGIN.md @@ -22,14 +22,16 @@ separate Neovim plugin that depends on upstream `fff.nvim`. }, keys = { { 'b', function() require('fff_plus').buffers() end, desc = 'FFF+ buffers' }, - { 'g', function() require('fff_plus').git_files() end, desc = 'FFF+ git files' }, + { 'g', function() require('fff_plus').tracked_files() end, desc = 'FFF+ tracked files' }, + { 's', function() require('fff_plus').git_status() end, desc = 'FFF+ git status' }, { 'c', function() require('fff_plus').colors() end, desc = 'FFF+ colors' }, }, } ``` The extension owns `require('fff_plus')`, `:FFFPlusBuffers`, -`:FFFPlusColors`, and `:FFFPlusGFiles`. It can optionally provide compatibility +`:FFFPlusColors`, `:FFFPlusGitFiles`, and `:FFFPlusGitStatus`. It retains +`:FFFPlusGFiles` for compatibility and can optionally provide fzf.vim-style commands with `legacy_commands = true`. ## Pros diff --git a/Makefile b/Makefile index 3c416975..d85dcb07 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL := bash .SHELLFLAGS := -o pipefail -ec -LUA_FILES := lua/fff_plus/init.lua lua/fff_plus/git_utils.lua lua/fff_plus/pickers/*.lua plugin/fff_plus.lua tests/test_fff_plus_extension.lua +LUA_FILES := lua/fff_plus/*.lua lua/fff_plus/pickers/*.lua plugin/fff_plus.lua tests/test_fff_plus_extension.lua .PHONY: all test test-lua lint format format-check diff --git a/README.md b/README.md index 34321524..7f70b7a1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ the Rust backend, binary downloader, file search, live grep, frecency index, and release pipeline. This plugin adds the picker workflows that are useful day to day but do not need to live in the backend project. +## Usage demo + +Tracked-file fuzzy search, Git-status diff preview and multi-select, quickfix, +and the fullscreen buffer picker are shown below. Click the preview for the +MP4 version. + +[![fff-plus.nvim picker usage demo](./assets/fff-plus-usage.gif)](./assets/fff-plus-usage.mp4) + ## Installation Install upstream `fff.nvim` first, then install `fff-plus.nvim`. @@ -36,7 +44,8 @@ Install upstream `fff.nvim` first, then install `fff-plus.nvim`. keys = { { '', function() require('fff_plus').buffers() end, desc = 'FFF+ buffers' }, { 'c', function() require('fff_plus').colors() end, desc = 'FFF+ colors' }, - { 'g', function() require('fff_plus').git_files() end, desc = 'FFF+ git files' }, + { 'g', function() require('fff_plus').tracked_files() end, desc = 'FFF+ tracked files' }, + { 's', function() require('fff_plus').git_status() end, desc = 'FFF+ git status' }, }, } ``` @@ -70,7 +79,33 @@ require('fff_plus').setup({ | --- | --- | --- | | `require('fff_plus').buffers()` | `:FFFPlusBuffers` | Switch between listed buffers, preview contents, and delete buffers | | `require('fff_plus').colors()` | `:FFFPlusColors` | Browse colorschemes with live preview and restore on cancel | -| `require('fff_plus').git_files()` | `:FFFPlusGFiles` | Browse files from `git status -s` with status indicators and preview | +| `require('fff_plus').tracked_files()` | `:FFFPlusGitFiles` | Browse files tracked by Git | +| `require('fff_plus').git_status()` | `:FFFPlusGitStatus` | Browse changed files with status indicators and diff preview | +| `require('fff_plus').git_files()` | `:FFFPlusGFiles` | Compatibility API for the Git-status picker | + +All pickers support ranked fuzzy matching and keep long result lists scrolled to +the selected item. Add `!` to an extension command to use the fullscreen +layout, for example `:FFFPlusBuffers!`. + +Buffers and both Git pickers support these actions using upstream FFF keymap +defaults: + +| Action | Default key | +| --- | --- | +| Toggle selection | `` | +| Send selected/current entries to quickfix | `` | +| Open in split / vertical split / tab | `` / `` / `` | +| Paste selected/current paths into the invoking buffer | `` | + +Override the paste key per call, or make the buffer picker jump to a window +that already displays the selected buffer: + +```lua +require('fff_plus').buffers({ + jump_to_existing = true, + keymaps = { paste = '' }, +}) +``` ## Compatibility Aliases @@ -87,7 +122,11 @@ That enables: - `:FFFBuffers` - `:Colors` -- `:GFiles` +- `:GFiles` (tracked files, matching fzf.vim semantics) + +The older `:FFFPlusGFiles` command and `git_files()` Lua API remain aliases for +the Git-status workflow. Prefer the explicit `FFFPlusGitFiles`/ +`tracked_files()` and `FFFPlusGitStatus`/`git_status()` names in new config. If you want old Lua call sites such as `require('fff').buffers()` to continue working, add a tiny shim in your config: @@ -99,6 +138,8 @@ local fff = require('fff') fff.buffers = plus.buffers fff.colors = plus.colors fff.git_files = plus.git_files +fff.git_status = plus.git_status +fff.tracked_files = plus.tracked_files ``` ## Maintenance Notes diff --git a/assets/fff-plus-usage.gif b/assets/fff-plus-usage.gif new file mode 100644 index 00000000..e8508694 Binary files /dev/null and b/assets/fff-plus-usage.gif differ diff --git a/assets/fff-plus-usage.mp4 b/assets/fff-plus-usage.mp4 new file mode 100644 index 00000000..7b7b08ec Binary files /dev/null and b/assets/fff-plus-usage.mp4 differ diff --git a/docs/research/fzf-vim-feature-parity.md b/docs/research/fzf-vim-feature-parity.md new file mode 100644 index 00000000..5ac6ef1c --- /dev/null +++ b/docs/research/fzf-vim-feature-parity.md @@ -0,0 +1,136 @@ +# fzf.vim feature-parity research + +Checked 2026-07-20. The primary external baseline is +[`junegunn/fzf.vim` at `d2a59a9`](https://github.com/junegunn/fzf.vim/tree/d2a59a992a2455f609c0fde2ebd84427ea8f919a). +The local side is the combined product: upstream +[`dmtrKovalenko/fff` at `073698c`](https://github.com/dmtrKovalenko/fff/tree/073698c8e7dad9f6106d23ef588f5ebc7f98b326) +plus this `fff-plus.nvim` checkout. + +> Implementation update: [issue #6](https://github.com/vinitkumar/fff-plus.nvim/issues/6) +> resolves the audit's extension-picker gaps +> for fuzzy matching, long-list scrolling, tracked/status Git separation, +> multi-select and quickfix, paste actions, fullscreen commands, existing-window +> buffer jumps, and Git diff previews. The command table below records the +> pre-implementation baseline that motivated that work. + +## Target resolution + +The request says “fzf.nvim”, but the intended target in this repository is +`junegunn/fzf.vim`: + +- The local README calls the aliases “fzf.vim-style workflows” + ([`README.md`, lines 75–90](../../README.md#L75-L90)). +- The three extension pickers identify themselves with fzf.vim's `:Buffers`, + `:Colors`, and `:GFiles?` commands + ([buffers](../../lua/fff_plus/pickers/buffers.lua#L1), + [colors](../../lua/fff_plus/pickers/colors.lua#L1), + [git status](../../lua/fff_plus/pickers/git_files.lua#L1)). +- Those exact commands appear in the + [official fzf.vim command table](https://github.com/junegunn/fzf.vim/blob/d2a59a992a2455f609c0fde2ebd84427ea8f919a/README.md#commands). + +This is not `ibhagwan/fzf-lua`, which is a separate and much broader Lua picker +suite with buffers/files, search, tags, Git, LSP/diagnostics, DAP, editor-state, +completion, resume, combine, and global-picker features +([official command list](https://github.com/ibhagwan/fzf-lua#commands)). It is +also not the literal-name [`samsze0/fzf.nvim`](https://github.com/samsze0/fzf.nvim), +whose README does not enumerate a stable implemented picker surface. If the +literal project was intended, a separate source-level audit is needed. + +## Status definitions + +| Status | Meaning | +| --- | --- | +| **Full** | The combined FFF setup provides the same user workflow and its important actions. | +| **Partial** | The core workflow exists, but search behavior, scope, or important actions differ. | +| **Alternative** | FFF reaches a similar outcome through a materially different workflow or engine. | +| **Missing** | No equivalent user-facing picker or action was found. | + +The status is about user workflows, not implementation parity with the `fzf` +binary. + +## Command-by-command parity + +The benchmark definitions in this table come from fzf.vim's +[official command list](https://github.com/junegunn/fzf.vim/blob/d2a59a992a2455f609c0fde2ebd84427ea8f919a/README.md#commands). + +| fzf.vim workflow | Combined FFF equivalent | Status | Evidence / gap | +| --- | --- | --- | --- | +| `:Files [PATH]` | `fff.find_files()` / `find_files_in_dir(path)` | **Full** | Upstream exposes both current-repository and specific-directory file pickers ([public API](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#public-api)). | +| `:GFiles [OPTS]` — tracked files from `git ls-files` | None | **Missing** | The local `:GFiles` alias is misleading: it calls `git_files()`, which reads `git status -s`, not `git ls-files` ([command registration](../../lua/fff_plus/init.lua#L126-L129), [source](../../lua/fff_plus/pickers/git_files.lua#L26-L30)). Upstream file search is Git-aware but is not a tracked-files-only picker. | +| `:GFiles?` — Git status files | `fff_plus.git_files()` / `:FFFPlusGFiles` | **Partial** | It reads and labels `git status -s` entries and supports edit/split/vsplit/tab ([source](../../lua/fff_plus/pickers/git_files.lua#L26-L98), [actions](../../lua/fff_plus/pickers/git_files.lua#L514-L536)). Unlike fzf, matching is literal case-insensitive substring, not fuzzy ([filter](../../lua/fff_plus/pickers/git_files.lua#L403-L415)); it previews file contents rather than the status diff and has no multi-select, quickfix, or paste action. fzf.vim documents `delta`-formatted diff previews for this workflow ([dependencies](https://github.com/junegunn/fzf.vim/blob/d2a59a992a2455f609c0fde2ebd84427ea8f919a/README.md#dependencies)). | +| `:Buffers` | `fff_plus.buffers()` / `:FFFPlusBuffers` | **Partial** | It lists MRU-sorted listed buffers, previews them, opens in edit/split/vsplit/tab, and can delete a buffer ([collection](../../lua/fff_plus/pickers/buffers.lua#L37-L59), [actions](../../lua/fff_plus/pickers/buffers.lua#L655-L710)). Matching is literal substring despite the “fuzzy” comment ([filter](../../lua/fff_plus/pickers/buffers.lua#L138-L153)); there is no multi-select or paste action. | +| `:Colors` | `fff_plus.colors()` / `:FFFPlusColors` | **Partial** | It live-previews schemes and restores the original on cancel ([preview/select](../../lua/fff_plus/pickers/colors.lua#L424-L496)). Matching is literal substring ([filter](../../lua/fff_plus/pickers/colors.lua#L94-L109)). `:Colors!` accepts a bang, but the documented fullscreen/no-preview behavior is not implemented: `opts.bang` is only merged into config ([registration](../../lua/fff_plus/init.lua#L87-L94), [open](../../lua/fff_plus/pickers/colors.lua#L513-L535)). | +| `:Ag [PATTERN]` | `fff.live_grep({ query = ... })` | **Alternative** | FFF provides indexed plain/regex/fuzzy content search rather than an `ag` result picker ([live grep modes](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#live-grep-modes)). No `ag` backend compatibility. | +| `:Rg [PATTERN]` — search then fuzzy-filter results | `fff.live_grep({ query = ... })` | **Alternative** | FFF searches its content index as the query changes; it does not expose fzf.vim's fixed ripgrep-result source followed by a separate fuzzy filter. It does provide query prefill and plain/regex/fuzzy modes ([live grep modes](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#live-grep-modes)). | +| `:RG [PATTERN]` — relaunch search on every keystroke | `fff.live_grep()` | **Alternative** | Same interactive project-content-search outcome, but backed by FFF's in-memory index rather than relaunching `rg` ([public API](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#public-api)). | +| `:Lines [QUERY]` — lines in loaded buffers | None | **Missing** | Project-wide live grep is not a loaded-buffers-only line picker. | +| `:BLines [QUERY]` — current-buffer lines | None | **Missing** | No current-buffer line picker was found. | +| `:Tags [PREFIX]` | None | **Missing** | No project tags picker or ctags integration was found. | +| `:BTags [QUERY]` | None | **Missing** | No current-buffer tags picker was found. | +| `:Changes` | None | **Missing** | No changelist picker was found. | +| `:Marks` / `:BMarks` | None | **Missing** | No global/current-buffer marks picker was found. | +| `:Jumps` | None | **Missing** | No jumplist picker was found. | +| `:Windows` | None | **Missing** | No window picker was found. Buffer selection is not window selection. | +| `:Locate PATTERN` | None | **Missing** | No picker over system `locate` output was found. | +| `:History` — old files and open buffers | Buffer picker only | **Partial** | The extension covers open buffers, but there is no `v:oldfiles`/recent-files picker ([documented pickers](../../README.md#L67-L73)). FFF's query-history scoring is not a recent-files picker. | +| `:History:` — command history | None | **Missing** | No command-history picker was found. | +| `:History/` — search history | None | **Missing** | FFF persists picker queries for ranking, but does not expose a search-history picker ([history configuration](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#configuration)). | +| `:Snippets` | None | **Missing** | No UltiSnips/snippet picker was found. | +| `:Commits [LOG_OPTS]` | None | **Missing** | Git-status files are not project commit history. | +| `:BCommits [LOG_OPTS]` | None | **Missing** | No current-buffer commit-history picker was found. | +| `:Commands` | None | **Missing** | No Ex-command picker was found. | +| `:Maps` | None | **Missing** | No normal-mode mapping picker was found. | +| `:Helptags` | None | **Missing** | No help-tag picker was found. | +| `:Filetypes` | None | **Missing** | No filetype picker was found. | + +## Cross-cutting UX and extensibility parity + +fzf.vim documents these behaviors alongside its commands and in its +[customization](https://github.com/junegunn/fzf.vim/blob/d2a59a992a2455f609c0fde2ebd84427ea8f919a/README.md#customization), +[advanced customization](https://github.com/junegunn/fzf.vim/blob/d2a59a992a2455f609c0fde2ebd84427ea8f919a/README.md#advanced-customization), +and [completion](https://github.com/junegunn/fzf.vim/blob/d2a59a992a2455f609c0fde2ebd84427ea8f919a/README.md#completion-functions) +sections. + +| Capability | Combined FFF status | Evidence / gap | +| --- | --- | --- | +| Open in tab, horizontal split, or vertical split | **Full for file-like pickers** | Upstream binds select/split/vsplit/tab and the buffer/Git-status extension pickers implement the same four actions ([upstream keymaps](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#configuration), [extension Git actions](../../lua/fff_plus/pickers/git_files.lua#L514-L536)). | +| Preview pane | **Partial / alternative** | Upstream FFF has configurable preview placement and size; buffer and Git-status pickers reuse it, while Colors performs a live colorscheme preview ([upstream preview config](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#configuration), [local dependency](../../README.md#L104-L117)). The extension has no runtime preview toggle, and its Git-status picker shows file contents rather than a diff. | +| Multi-select and send results to quickfix/location list | **Partial** | Upstream file/grep pickers provide toggle-select and send-to-quickfix keymaps ([configuration](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#configuration)). Extension pickers have only single-item selection; there is no location-list target. | +| Paste selected item into the current buffer | **Missing** | No counterpart to fzf.vim's configurable `ALT-ENTER` paste action was found. | +| Bang command opens fullscreen | **Missing** | `FFFPlusGFiles` registers `bang = true` but discards the command callback options; Colors passes `bang` without acting on it ([commands](../../lua/fff_plus/init.lua#L87-L99), [Colors open](../../lua/fff_plus/pickers/colors.lua#L513-L535)). | +| True fuzzy matching in every picker | **Partial** | Upstream file/grep use FFF's typo-tolerant search, but all three extension pickers use `string.find(..., true)`, i.e. case-insensitive literal substring matching ([buffers](../../lua/fff_plus/pickers/buffers.lua#L142-L150), [colors](../../lua/fff_plus/pickers/colors.lua#L98-L106), [Git status](../../lua/fff_plus/pickers/git_files.lua#L403-L414)). | +| Jump to the window already showing a selected buffer | **Missing** | Buffer selection executes `:buffer` in the current window; there is no equivalent to fzf.vim's `g:fzf_vim.buffers_jump` ([selection](../../lua/fff_plus/pickers/buffers.lua#L655-L678)). | +| Per-command options | **Alternative / partial** | FFF and FFF+ accept Lua option tables and merge picker config, but do not expose arbitrary per-command fzf CLI options ([FFF public API](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#public-api), [FFF+ dispatch](../../lua/fff_plus/init.lua#L37-L70)). | +| Configurable command prefix | **Missing** | FFF+ offers only fixed optional legacy aliases, not a general prefix ([configuration and aliases](../../lua/fff_plus/init.lua#L9-L11), [registration](../../lua/fff_plus/init.lua#L101-L129)). | +| Public API for custom picker sources/sinks | **Partial** | Upstream has programmatic `file_search` and `content_search`, but FFF+ exposes a fixed three-picker registry. There is no public equivalent of `fzf#wrap`, arbitrary source/sink specs, or `fzf#vim#with_preview` ([upstream programmatic API](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md#public-api), [local registry](../../lua/fff_plus/init.lua#L3-L7)). | +| Insert-mode word/path/file/line completion | **Missing** | No fuzzy completion functions or `` completion mappings were found. | +| Mapping selector across normal/insert/visual/operator modes | **Missing** | No equivalent to fzf.vim's mapping-selector `` mappings was found. | +| Long-list viewport scrolling in extension pickers | **Missing** | The extension renderers always draw the first `win_height` items, while movement can advance the logical cursor past that slice; there is no scroll offset ([buffer render](../../lua/fff_plus/pickers/buffers.lua#L428-L458), [buffer movement](../../lua/fff_plus/pickers/buffers.lua#L625-L641), [Git-status render](../../lua/fff_plus/pickers/git_files.lua#L302-L332)). Large buffer and Git-status lists can therefore select or preview an off-screen item without visibly rendering it. | + +## What is materially missing + +The largest parity gaps are not more file search. They are: + +1. **Editor-state pickers:** buffer/current-buffer lines, changes, marks, jumps, + windows, old files, command/search history, commands, mappings, help tags, + and filetypes. +2. **Code and Git navigation:** project/buffer tags, tracked-only Git files, + project commits, and per-buffer commits. +3. **Shared picker behavior:** true fuzzy matching in extension pickers, + extension-wide multi-select/quickfix, paste action, functional bang/fullscreen, + existing-window buffer jumps, diff previews for Git status, and correct + viewport scrolling for long lists. +4. **Extension surface:** arbitrary/custom picker sources, sinks/actions, preview + helpers, and insert-mode completion. + +[Issue #6](https://github.com/vinitkumar/fff-plus.nvim/issues/6) corrects the +`:GFiles` compatibility alias to use tracked files from `git ls-files` and adds +explicit Git-files and Git-status APIs. + +## Sources + +- [fzf.vim README at audited revision](https://github.com/junegunn/fzf.vim/blob/d2a59a992a2455f609c0fde2ebd84427ea8f919a/README.md) +- [upstream FFF README at audited revision](https://github.com/dmtrKovalenko/fff/blob/073698c8e7dad9f6106d23ef588f5ebc7f98b326/README.md) +- [fzf-lua official README](https://github.com/ibhagwan/fzf-lua) +- [literal-name samsze0/fzf.nvim README](https://github.com/samsze0/fzf.nvim) +- Local files linked inline above diff --git a/lua/fff_plus/git_source.lua b/lua/fff_plus/git_source.lua new file mode 100644 index 00000000..fdb1fe34 --- /dev/null +++ b/lua/fff_plus/git_source.lua @@ -0,0 +1,95 @@ +local M = {} + +local function split_nul(output) + local values = {} + local start = 1 + + while start <= #output do + local finish = output:find('\0', start, true) + if not finish then break end + table.insert(values, output:sub(start, finish - 1)) + start = finish + 1 + end + + return values +end + +local function classify(index_status, worktree_status) + if index_status == '?' and worktree_status == '?' then return 'untracked' end + if index_status == '!' and worktree_status == '!' then return 'ignored' end + if index_status == 'R' or worktree_status == 'R' or index_status == 'C' or worktree_status == 'C' then + return 'renamed' + end + if index_status == 'A' then return 'staged_new' end + if index_status == 'M' then return 'staged_modified' end + if index_status == 'D' then return 'staged_deleted' end + if worktree_status == 'M' then return 'modified' end + if worktree_status == 'D' then return 'deleted' end + if worktree_status == 'A' then return 'untracked' end + return 'unknown' +end + +function M.parse_tracked(output) return split_nul(output or '') end + +function M.parse_status(output) + local fields = split_nul(output or '') + local entries = {} + local index = 1 + + while index <= #fields do + local record = fields[index] + if #record >= 3 then + local index_status = record:sub(1, 1) + local worktree_status = record:sub(2, 2) + local renamed = index_status == 'R' or worktree_status == 'R' or index_status == 'C' or worktree_status == 'C' + local entry = { + relative_path = record:sub(4), + git_status = classify(index_status, worktree_status), + } + + if renamed then + entry.old_path = fields[index + 1] + index = index + 1 + end + + table.insert(entries, entry) + end + index = index + 1 + end + + return entries +end + +function M.run(git_root, args) + local command = { 'git', '-C', vim.fn.shellescape(git_root) } + for _, argument in ipairs(args) do + table.insert(command, vim.fn.shellescape(argument)) + end + + local handle = io.popen(table.concat(command, ' ') .. ' 2>/dev/null') + if not handle then return nil end + local output = handle:read('*a') + local ok = handle:close() + if not ok then return nil end + return output +end + +function M.tracked(git_root) + local output = M.run(git_root, { 'ls-files', '-z' }) + if not output then return {} end + return M.parse_tracked(output) +end + +function M.status(git_root) + local output = M.run(git_root, { 'status', '--porcelain=v1', '-z', '--untracked-files=all' }) + if not output then return {} end + return M.parse_status(output) +end + +function M.diff(git_root, relative_path) + local output = M.run(git_root, { 'diff', '--no-ext-diff', 'HEAD', '--', relative_path }) + if not output or output == '' then return nil end + return output +end + +return M diff --git a/lua/fff_plus/init.lua b/lua/fff_plus/init.lua index dcf931b8..c7c38d88 100644 --- a/lua/fff_plus/init.lua +++ b/lua/fff_plus/init.lua @@ -69,6 +69,13 @@ function M.colors(opts) return M.open('colors', opts) end function M.git_files(opts) return M.open('git_files', opts) end +function M.git_status(opts) return M.open('git_files', opts) end + +function M.tracked_files(opts) + opts = vim.tbl_deep_extend('force', { source = 'tracked', title = 'Git Files' }, opts or {}) + return M.open('git_files', opts) +end + function M.register_commands() create_command( 'FFFPlusBuffers', @@ -76,26 +83,38 @@ function M.register_commands() M.buffers({ title = 'Buffers', prompt = opts.args ~= '' and opts.args or nil, + fullscreen = opts.bang, }) end, { nargs = '?', + bang = true, desc = 'Browse and switch between open buffers with fff-plus.nvim', } ) create_command('FFFPlusColors', function(opts) M.colors({ - bang = opts.bang, + fullscreen = opts.bang, }) end, { bang = true, desc = 'Browse and switch colorschemes with fff-plus.nvim', }) - create_command('FFFPlusGFiles', function() M.git_files() end, { + create_command('FFFPlusGFiles', function(opts) M.git_files({ fullscreen = opts.bang }) end, { + bang = true, + desc = 'Browse Git status files with fff-plus.nvim (compatibility name)', + }) + + create_command('FFFPlusGitFiles', function(opts) M.tracked_files({ fullscreen = opts.bang }) end, { + bang = true, + desc = 'Browse files tracked by Git with fff-plus.nvim', + }) + + create_command('FFFPlusGitStatus', function(opts) M.git_status({ fullscreen = opts.bang }) end, { bang = true, - desc = 'Browse git status files with fff-plus.nvim', + desc = 'Browse Git status files with fff-plus.nvim', }) if not M.config.legacy_commands then return end @@ -106,26 +125,28 @@ function M.register_commands() M.buffers({ title = 'Buffers', prompt = opts.args ~= '' and opts.args or nil, + fullscreen = opts.bang, }) end, { nargs = '?', + bang = true, desc = 'Browse and switch between open buffers with fff-plus.nvim', } ) create_command('Colors', function(opts) M.colors({ - bang = opts.bang, + fullscreen = opts.bang, }) end, { bang = true, desc = 'Browse and switch colorschemes with fff-plus.nvim', }) - create_command('GFiles', function() M.git_files() end, { + create_command('GFiles', function(opts) M.tracked_files({ fullscreen = opts.bang }) end, { bang = true, - desc = 'Browse git status files with fff-plus.nvim', + desc = 'Browse files tracked by Git with fff-plus.nvim', }) end diff --git a/lua/fff_plus/layout.lua b/lua/fff_plus/layout.lua new file mode 100644 index 00000000..de36b384 --- /dev/null +++ b/lua/fff_plus/layout.lua @@ -0,0 +1,31 @@ +local M = {} + +local function resolve(value, fallback, columns, lines) + if type(value) == 'function' then return value(columns, lines) end + return value or fallback +end + +function M.frame(columns, lines, config, fullscreen) + if fullscreen then + return { + width = math.max(1, columns - 4), + height = math.max(1, lines - 4), + col = 1, + row = 0, + } + end + + local width_ratio = resolve(config.width, 0.8, columns, lines) + local height_ratio = resolve(config.height, 0.8, columns, lines) + local width = math.max(1, math.floor(columns * width_ratio)) + local height = math.max(1, math.floor(lines * height_ratio)) + + return { + width = width, + height = height, + col = math.floor((columns - width) / 2), + row = math.floor((lines - height) / 2), + } +end + +return M diff --git a/lua/fff_plus/matcher.lua b/lua/fff_plus/matcher.lua new file mode 100644 index 00000000..0060bf6a --- /dev/null +++ b/lua/fff_plus/matcher.lua @@ -0,0 +1,56 @@ +local M = {} + +local function is_boundary(char) return char == '' or char:find('[%s%p]') ~= nil end + +function M.score(text, query) + text = tostring(text or ''):lower() + query = tostring(query or ''):lower() + if query == '' then return 0 end + + local score = 0 + local position = 1 + local previous = 0 + + for index = 1, #query do + local found = text:find(query:sub(index, index), position, true) + if not found then return nil end + + score = score + 10 + if found == previous + 1 then score = score + 8 end + if found == 1 or is_boundary(text:sub(found - 1, found - 1)) then score = score + 6 end + + score = score - (found * 0.1) + previous = found + position = found + 1 + end + + local substring_start = text:find(query, 1, true) + if substring_start then score = score + 50 - substring_start end + if substring_start == 1 then score = score + 100 end + if text == query then score = score + 200 end + + return score - (#text * 0.01) +end + +function M.filter(items, query, get_text) + if not query or query == '' then return items end + + local matches = {} + for index, item in ipairs(items) do + local score = M.score(get_text(item), query) + if score then table.insert(matches, { item = item, score = score, index = index }) end + end + + table.sort(matches, function(left, right) + if left.score == right.score then return left.index < right.index end + return left.score > right.score + end) + + local filtered = {} + for _, match in ipairs(matches) do + table.insert(filtered, match.item) + end + return filtered +end + +return M diff --git a/lua/fff_plus/pickers/buffers.lua b/lua/fff_plus/pickers/buffers.lua index bb0913b7..ed60846a 100644 --- a/lua/fff_plus/pickers/buffers.lua +++ b/lua/fff_plus/pickers/buffers.lua @@ -7,6 +7,10 @@ local conf = require('fff.conf') local preview = require('fff.file_picker.preview') local icons = require('fff.file_picker.icons') local utils = require('fff.utils') +local layout = require('fff_plus.layout') +local matcher = require('fff_plus.matcher') +local selection = require('fff_plus.selection') +local viewport = require('fff_plus.viewport') --- Buffer access tracking (similar to g:fzf#vim#buffers in fzf.vim) --- Stores buffer number -> access timestamp @@ -135,22 +139,12 @@ function M.get_buffer_items() return items end ---- Filter buffers by query (simple fuzzy match) +--- Filter and rank buffers by fuzzy query --- @param items table List of buffer items --- @param query string Search query --- @return table Filtered list of buffer items function M.filter_buffers(items, query) - if not query or query == '' then return items end - - local filtered = {} - local query_lower = query:lower() - - for _, item in ipairs(items) do - local match_target = (item.display_name or ''):lower() - if match_target:find(query_lower, 1, true) then table.insert(filtered, item) end - end - - return filtered + return matcher.filter(items, query, function(item) return item.display_name or '' end) end -- ============================================================================ @@ -172,6 +166,8 @@ M.state = { config = nil, ns_id = nil, last_preview_file = nil, + selected = {}, + origin_win = nil, } local function get_prompt_position() @@ -195,16 +191,11 @@ function M.create_ui() local terminal_width = vim.o.columns local terminal_height = vim.o.lines - -- Calculate dimensions - local width_ratio = config.layout.width or 0.8 - local height_ratio = config.layout.height or 0.8 - if type(width_ratio) == 'function' then width_ratio = width_ratio(terminal_width, terminal_height) end - if type(height_ratio) == 'function' then height_ratio = height_ratio(terminal_width, terminal_height) end - - local width = math.floor(terminal_width * width_ratio) - local height = math.floor(terminal_height * height_ratio) - local col = math.floor((terminal_width - width) / 2) - local row = math.floor((terminal_height - height) / 2) + local frame = layout.frame(terminal_width, terminal_height, config.layout or {}, config.fullscreen) + local width = frame.width + local height = frame.height + local col = frame.col + local row = frame.row local prompt_position = get_prompt_position() @@ -241,7 +232,7 @@ function M.create_ui() border = prompt_position == 'bottom' and { '┌', '─', '┐', '│', '', '', '', '│' } or { '├', '─', '┤', '│', '┘', '─', '└', '│' }, style = 'minimal', - title = prompt_position == 'bottom' and ' Buffers ' or nil, + title = prompt_position == 'bottom' and ' ' .. (config.title or 'Buffers') .. ' ' or nil, title_pos = prompt_position == 'bottom' and 'left' or nil, }) @@ -271,7 +262,7 @@ function M.create_ui() border = prompt_position == 'bottom' and { '├', '─', '┤', '│', '┘', '─', '└', '│' } or { '┌', '─', '┐', '│', '', '', '', '│' }, style = 'minimal', - title = prompt_position == 'top' and ' Buffers ' or nil, + title = prompt_position == 'top' and ' ' .. (config.title or 'Buffers') .. ' ' or nil, title_pos = prompt_position == 'top' and 'left' or nil, }) @@ -381,6 +372,10 @@ function M.setup_keymaps() vim.keymap.set('i', keymaps.preview_scroll_down, M.scroll_preview_down, input_opts) end + vim.keymap.set('i', keymaps.toggle_select or '', M.toggle_selection, input_opts) + vim.keymap.set('i', keymaps.send_to_quickfix or '', M.send_to_quickfix, input_opts) + vim.keymap.set('i', keymaps.paste or '', M.paste_selection, input_opts) + -- Delete buffer with vim.keymap.set('i', '', M.delete_buffer, input_opts) @@ -413,12 +408,7 @@ function M.update_results() M.state.items = M.get_buffer_items() M.state.filtered_items = M.filter_buffers(M.state.items, M.state.query) - local prompt_position = get_prompt_position() - if prompt_position == 'bottom' then - M.state.cursor = #M.state.filtered_items > 0 and #M.state.filtered_items or 1 - else - M.state.cursor = 1 - end + M.state.cursor = 1 M.render_list() M.update_preview() @@ -431,20 +421,17 @@ function M.render_list() local items = M.state.filtered_items local win_height = vim.api.nvim_win_get_height(M.state.list_win) local win_width = vim.api.nvim_win_get_width(M.state.list_win) - local display_count = math.min(#items, win_height) local prompt_position = get_prompt_position() - - local empty_lines_needed = 0 - local cursor_line = 0 - - if #items > 0 then - if prompt_position == 'bottom' then - empty_lines_needed = win_height - display_count - cursor_line = empty_lines_needed + M.state.cursor - else - cursor_line = M.state.cursor - end - cursor_line = math.max(1, math.min(cursor_line, win_height)) + local view = viewport.calculate(#items, M.state.cursor, win_height, prompt_position) + local empty_lines_needed = view.padding + local cursor_line = view.cursor_line + local first_index = view.reverse and view.last or view.first + local last_index = view.reverse and view.first or view.last + local index_step = view.reverse and -1 or 1 + + local function visible_line(item_index) + local offset = view.reverse and (view.last - item_index) or (item_index - view.first) + return empty_lines_needed + offset + 1 end local lines = {} @@ -457,8 +444,8 @@ function M.render_list() end -- Format each buffer line - for i = 1, display_count do - local item = items[i] + for item_index = first_index, last_index, index_step do + local item = items[item_index] local icon, icon_hl = icons.get_icon(item.name, item.extension, false) -- Build the line: [bufnr] status icon name flags path @@ -488,9 +475,9 @@ function M.render_list() vim.api.nvim_buf_add_highlight(M.state.list_buf, M.state.ns_id, M.state.config.hl.cursor, cursor_line - 1, 0, -1) -- Add highlights for each visible item - for i = 1, display_count do - local item = items[i] - local line_idx = empty_lines_needed + i + for item_index = first_index, last_index, index_step do + local item = items[item_index] + local line_idx = visible_line(item_index) local is_cursor_line = line_idx == cursor_line -- Highlight buffer number @@ -546,8 +533,14 @@ function M.render_list() end end + if M.state.selected[item.bufnr] then + vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, { + sign_text = '▊', + sign_hl_group = 'Visual', + priority = 1100, + }) -- Sign for current buffer indicator - if item.current and not is_cursor_line then + elseif item.current and not is_cursor_line then vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, { sign_text = '▎', sign_hl_group = 'Conditional', @@ -626,7 +619,7 @@ function M.move_up() if not M.state.active then return end if #M.state.filtered_items == 0 then return end - M.state.cursor = math.max(M.state.cursor - 1, 1) + M.state.cursor = viewport.move(M.state.cursor, #M.state.filtered_items, 'up', get_prompt_position()) M.render_list() M.update_preview() end @@ -635,7 +628,7 @@ function M.move_down() if not M.state.active then return end if #M.state.filtered_items == 0 then return end - M.state.cursor = math.min(M.state.cursor + 1, #M.state.filtered_items) + M.state.cursor = viewport.move(M.state.cursor, #M.state.filtered_items, 'down', get_prompt_position()) M.render_list() M.update_preview() end @@ -652,6 +645,58 @@ function M.scroll_preview_down() preview.scroll(math.floor(win_height / 2)) end +local function current_item() + if #M.state.filtered_items == 0 or M.state.cursor > #M.state.filtered_items then return nil end + return M.state.filtered_items[M.state.cursor] +end + +local function chosen_items() + return selection.collect(M.state.items, M.state.selected, current_item(), function(item) return item.bufnr end) +end + +function M.toggle_selection() + local item = current_item() + if not item then return end + selection.toggle(M.state.selected, item.bufnr) + M.render_list() +end + +function M.send_to_quickfix() + local items = chosen_items() + if #items == 0 then return end + + local quickfix = {} + for _, item in ipairs(items) do + table.insert(quickfix, { + bufnr = item.bufnr, + lnum = math.max(item.line or 1, 1), + col = 1, + text = item.display_name, + }) + end + + M.close() + vim.fn.setqflist({}, ' ', { title = 'FFF+ Buffers', items = quickfix }) + vim.cmd('copen') +end + +function M.paste_selection() + local items = chosen_items() + if #items == 0 then return end + local origin_win = M.state.origin_win + M.close() + if origin_win and vim.api.nvim_win_is_valid(origin_win) then vim.api.nvim_set_current_win(origin_win) end + selection.put(items, function(item) return item.path ~= '' and item.path or item.display_name end) +end + +function M.find_existing_window(bufnr) + if not (M.state.config and M.state.config.jump_to_existing) then return nil end + for _, win in ipairs(vim.fn.win_findbuf(bufnr)) do + if vim.api.nvim_win_is_valid(win) then return win end + end + return nil +end + function M.select(action) if not M.state.active then return end @@ -668,7 +713,12 @@ function M.select(action) M.close() if action == 'edit' then - vim.cmd('buffer ' .. bufnr) + local existing_win = M.find_existing_window(bufnr) + if existing_win then + vim.api.nvim_set_current_win(existing_win) + else + vim.cmd('buffer ' .. bufnr) + end elseif action == 'split' then vim.cmd('sbuffer ' .. bufnr) elseif action == 'vsplit' then @@ -745,6 +795,8 @@ function M.close() M.state.query = '' M.state.ns_id = nil M.state.last_preview_file = nil + M.state.selected = {} + M.state.origin_win = nil pcall(vim.api.nvim_del_augroup_by_name, 'fff_plus_buffer_picker_focus') end @@ -754,6 +806,8 @@ end function M.open(opts) if M.state.active then return end + M.state.origin_win = vim.api.nvim_get_current_win() + local config = conf.get() local merged_config = vim.tbl_deep_extend('force', config or {}, opts or {}) @@ -774,13 +828,7 @@ function M.open(opts) return end - -- Set initial cursor position - local prompt_position = get_prompt_position() - if prompt_position == 'bottom' then - M.state.cursor = #M.state.filtered_items > 0 and #M.state.filtered_items or 1 - else - M.state.cursor = 1 - end + M.state.cursor = 1 M.render_list() M.update_preview() diff --git a/lua/fff_plus/pickers/colors.lua b/lua/fff_plus/pickers/colors.lua index 70e3fd83..d781e3db 100644 --- a/lua/fff_plus/pickers/colors.lua +++ b/lua/fff_plus/pickers/colors.lua @@ -5,6 +5,9 @@ local M = {} local conf = require('fff.conf') local utils = require('fff.utils') +local layout = require('fff_plus.layout') +local matcher = require('fff_plus.matcher') +local viewport = require('fff_plus.viewport') --- State for tracking original colorscheme M.original_colorscheme = nil @@ -91,22 +94,12 @@ function M.get_colorscheme_items() return items end ---- Filter colorschemes by query (simple fuzzy match) +--- Filter and rank colorschemes by fuzzy query --- @param items table List of colorscheme items --- @param query string Search query --- @return table Filtered list of colorscheme items function M.filter_colorschemes(items, query) - if not query or query == '' then return items end - - local filtered = {} - local query_lower = query:lower() - - for _, item in ipairs(items) do - local match_target = (item.name or ''):lower() - if match_target:find(query_lower, 1, true) then table.insert(filtered, item) end - end - - return filtered + return matcher.filter(items, query, function(item) return item.name or '' end) end -- ============================================================================ @@ -140,15 +133,9 @@ function M.create_ui() local terminal_width = vim.o.columns local terminal_height = vim.o.lines - - -- Calculate dimensions - local width_ratio = config.layout.width or 0.8 - local height_ratio = config.layout.height or 0.8 - if type(width_ratio) == 'function' then width_ratio = width_ratio(terminal_width, terminal_height) end - if type(height_ratio) == 'function' then height_ratio = height_ratio(terminal_width, terminal_height) end - - local width = math.floor(terminal_width * width_ratio) - local height = math.floor(terminal_height * height_ratio) + local frame = layout.frame(terminal_width, terminal_height, config.layout or {}, config.fullscreen) + local width = frame.width + local height = frame.height -- For colors picker, use a smaller window that fits content nicely local num_items = #M.state.items @@ -157,12 +144,22 @@ function M.create_ui() max_name_width = math.max(max_name_width, #item.name) end - -- Size window to fit content (like fzf.vim) - local list_width = math.min(math.max(max_name_width + 10, 30), width) - local list_height = math.min(num_items + 2, height - 4) - - local col = math.floor((terminal_width - list_width) / 2) - local row = math.floor((terminal_height - list_height - 4) / 2) + local list_width + local list_height + local col + local row + if config.fullscreen then + list_width = width + list_height = math.max(1, height - 4) + col = frame.col + row = frame.row + else + -- Size window to fit content (like fzf.vim) + list_width = math.min(math.max(max_name_width + 10, 30), width) + list_height = math.max(1, math.min(num_items + 2, height - 4)) + col = math.floor((terminal_width - list_width) / 2) + row = math.floor((terminal_height - list_height - 4) / 2) + end local prompt_position = get_prompt_position() @@ -184,7 +181,7 @@ function M.create_ui() border = prompt_position == 'bottom' and { '┌', '─', '┐', '│', '', '', '', '│' } or { '├', '─', '┤', '│', '┘', '─', '└', '│' }, style = 'minimal', - title = prompt_position == 'bottom' and ' Colors ' or nil, + title = prompt_position == 'bottom' and ' ' .. (config.title or 'Colors') .. ' ' or nil, title_pos = prompt_position == 'bottom' and 'left' or nil, }) @@ -199,7 +196,7 @@ function M.create_ui() border = prompt_position == 'bottom' and { '├', '─', '┤', '│', '┘', '─', '└', '│' } or { '┌', '─', '┐', '│', '', '', '', '│' }, style = 'minimal', - title = prompt_position == 'top' and ' Colors ' or nil, + title = prompt_position == 'top' and ' ' .. (config.title or 'Colors') .. ' ' or nil, title_pos = prompt_position == 'top' and 'left' or nil, }) @@ -314,12 +311,7 @@ function M.update_results() -- Filter colorschemes M.state.filtered_items = M.filter_colorschemes(M.state.items, M.state.query) - local prompt_position = get_prompt_position() - if prompt_position == 'bottom' then - M.state.cursor = #M.state.filtered_items > 0 and #M.state.filtered_items or 1 - else - M.state.cursor = 1 - end + M.state.cursor = 1 M.render_list() M.update_status() @@ -332,20 +324,17 @@ function M.render_list() local items = M.state.filtered_items local win_height = vim.api.nvim_win_get_height(M.state.list_win) local win_width = vim.api.nvim_win_get_width(M.state.list_win) - local display_count = math.min(#items, win_height) local prompt_position = get_prompt_position() - - local empty_lines_needed = 0 - local cursor_line = 0 - - if #items > 0 then - if prompt_position == 'bottom' then - empty_lines_needed = win_height - display_count - cursor_line = empty_lines_needed + M.state.cursor - else - cursor_line = M.state.cursor - end - cursor_line = math.max(1, math.min(cursor_line, win_height)) + local view = viewport.calculate(#items, M.state.cursor, win_height, prompt_position) + local empty_lines_needed = view.padding + local cursor_line = view.cursor_line + local first_index = view.reverse and view.last or view.first + local last_index = view.reverse and view.first or view.last + local index_step = view.reverse and -1 or 1 + + local function visible_line(item_index) + local offset = view.reverse and (view.last - item_index) or (item_index - view.first) + return empty_lines_needed + offset + 1 end local lines = {} @@ -358,8 +347,8 @@ function M.render_list() end -- Format each colorscheme line - for i = 1, display_count do - local item = items[i] + for item_index = first_index, last_index, index_step do + local item = items[item_index] local indicator = item.current and '* ' or ' ' local line = indicator .. item.name @@ -383,9 +372,9 @@ function M.render_list() vim.api.nvim_buf_add_highlight(M.state.list_buf, M.state.ns_id, M.state.config.hl.cursor, cursor_line - 1, 0, -1) -- Add highlights for each visible item - for i = 1, display_count do - local item = items[i] - local line_idx = empty_lines_needed + i + for item_index = first_index, last_index, index_step do + local item = items[item_index] + local line_idx = visible_line(item_index) local is_cursor_line = line_idx == cursor_line -- Highlight current colorscheme indicator @@ -439,7 +428,7 @@ function M.move_up() if not M.state.active then return end if #M.state.filtered_items == 0 then return end - M.state.cursor = math.max(M.state.cursor - 1, 1) + M.state.cursor = viewport.move(M.state.cursor, #M.state.filtered_items, 'up', get_prompt_position()) M.render_list() M.preview_colorscheme() end @@ -448,7 +437,7 @@ function M.move_down() if not M.state.active then return end if #M.state.filtered_items == 0 then return end - M.state.cursor = math.min(M.state.cursor + 1, #M.state.filtered_items) + M.state.cursor = viewport.move(M.state.cursor, #M.state.filtered_items, 'down', get_prompt_position()) M.render_list() M.preview_colorscheme() end @@ -540,13 +529,7 @@ function M.open(opts) return end - -- Set initial cursor position - local prompt_position = get_prompt_position() - if prompt_position == 'bottom' then - M.state.cursor = #M.state.filtered_items > 0 and #M.state.filtered_items or 1 - else - M.state.cursor = 1 - end + M.state.cursor = 1 M.render_list() M.update_status() diff --git a/lua/fff_plus/pickers/git_files.lua b/lua/fff_plus/pickers/git_files.lua index 47c0cf24..a6687f50 100644 --- a/lua/fff_plus/pickers/git_files.lua +++ b/lua/fff_plus/pickers/git_files.lua @@ -6,6 +6,11 @@ local M = {} local conf = require('fff.conf') local preview = require('fff.file_picker.preview') local git_utils = require('fff_plus.git_utils') +local git_source = require('fff_plus.git_source') +local layout = require('fff_plus.layout') +local matcher = require('fff_plus.matcher') +local selection = require('fff_plus.selection') +local viewport = require('fff_plus.viewport') -- Initialize preview module with config (required before using preview functions) local preview_config = conf.get().preview @@ -23,78 +28,39 @@ function M.get_git_root() return result:gsub('\n', '') end ---- Get git status files from git status output +local function format_git_file(git_root, relative_path, git_status, old_path) + local name = vim.fn.fnamemodify(relative_path, ':t') + return { + name = name, + path = git_root .. '/' .. relative_path, + relative_path = relative_path, + old_path = old_path, + directory = vim.fn.fnamemodify(relative_path, ':h'), + extension = vim.fn.fnamemodify(name, ':e'), + git_status = git_status, + is_dir = false, + } +end + +--- Get Git status files from porcelain output --- @param git_root string Git root directory --- @return table List of git files with status function M.get_git_status_files(git_root) - local handle = io.popen('git -C ' .. vim.fn.shellescape(git_root) .. ' status -s 2>/dev/null') - if not handle then return {} end - local files = {} - local seen = {} - - for line in handle:lines() do - if line:len() >= 3 then - -- Git status format: XY filename - -- X = index status, Y = worktree status - local index_status = line:sub(1, 1) - local worktree_status = line:sub(2, 2) - local filepath = line:sub(4) - - -- Handle renamed files: R old -> new - if index_status == 'R' or worktree_status == 'R' then - local arrow_pos = filepath:find(' %-> ') - if arrow_pos then - filepath = filepath:sub(arrow_pos + 4) -- Get the new filename - end - end - - -- Skip duplicates - if not seen[filepath] then - seen[filepath] = true - - -- Parse git status code (XY format) - -- X = staged (index), Y = unstaged (worktree) - local git_status = 'unknown' - - if index_status == '?' and worktree_status == '?' then - git_status = 'untracked' - elseif index_status == '!' and worktree_status == '!' then - git_status = 'ignored' - elseif index_status == 'A' then - git_status = 'staged_new' - elseif index_status == 'M' then - git_status = 'staged_modified' - elseif index_status == 'D' then - git_status = 'staged_deleted' - elseif index_status == 'R' then - git_status = 'renamed' - elseif worktree_status == 'M' then - git_status = 'modified' - elseif worktree_status == 'D' then - git_status = 'deleted' - elseif worktree_status == 'A' then - git_status = 'untracked' -- Added in worktree only (like untracked) - end - - local full_path = git_root .. '/' .. filepath - local name = vim.fn.fnamemodify(filepath, ':t') - local directory = vim.fn.fnamemodify(filepath, ':h') - - table.insert(files, { - name = name, - path = full_path, - relative_path = filepath, - directory = directory, - extension = vim.fn.fnamemodify(name, ':e'), - git_status = git_status, - is_dir = false, - }) - end - end + for _, entry in ipairs(git_source.status(git_root)) do + table.insert(files, format_git_file(git_root, entry.relative_path, entry.git_status, entry.old_path)) end + return files +end - handle:close() +--- Get files tracked by Git +--- @param git_root string Git root directory +--- @return table List of tracked files +function M.get_tracked_files(git_root) + local files = {} + for _, relative_path in ipairs(git_source.tracked(git_root)) do + table.insert(files, format_git_file(git_root, relative_path, 'clean')) + end return files end @@ -117,6 +83,9 @@ M.state = { config = nil, ns_id = nil, last_preview_file = nil, + source = 'status', + selected = {}, + origin_win = nil, } local function get_prompt_position() @@ -140,16 +109,11 @@ function M.create_ui() local terminal_width = vim.o.columns local terminal_height = vim.o.lines - -- Calculate dimensions - local width_ratio = config.layout.width or 0.8 - local height_ratio = config.layout.height or 0.8 - if type(width_ratio) == 'function' then width_ratio = width_ratio(terminal_width, terminal_height) end - if type(height_ratio) == 'function' then height_ratio = height_ratio(terminal_width, terminal_height) end - - local width = math.floor(terminal_width * width_ratio) - local height = math.floor(terminal_height * height_ratio) - local col = math.floor((terminal_width - width) / 2) - local row = math.floor((terminal_height - height) / 2) + local frame = layout.frame(terminal_width, terminal_height, config.layout or {}, config.fullscreen) + local width = frame.width + local height = frame.height + local col = frame.col + local row = frame.row local prompt_position = get_prompt_position() @@ -186,7 +150,7 @@ function M.create_ui() border = prompt_position == 'bottom' and { '┌', '─', '┐', '│', '', '', '', '│' } or { '├', '─', '┤', '│', '┘', '─', '└', '│' }, style = 'minimal', - title = prompt_position == 'bottom' and ' Git Files ' or nil, + title = prompt_position == 'bottom' and ' ' .. (config.title or 'Git Files') .. ' ' or nil, title_pos = prompt_position == 'bottom' and 'left' or nil, }) @@ -216,7 +180,7 @@ function M.create_ui() border = prompt_position == 'bottom' and { '├', '─', '┤', '│', '┘', '─', '└', '│' } or { '┌', '─', '┐', '│', '', '', '', '│' }, style = 'minimal', - title = prompt_position == 'top' and ' Git Files ' or nil, + title = prompt_position == 'top' and ' ' .. (config.title or 'Git Files') .. ' ' or nil, title_pos = prompt_position == 'top' and 'left' or nil, }) @@ -305,20 +269,17 @@ function M.render_list() local items = M.state.filtered_items local win_height = vim.api.nvim_win_get_height(M.state.list_win) local win_width = vim.api.nvim_win_get_width(M.state.list_win) - local display_count = math.min(#items, win_height) local prompt_position = get_prompt_position() - - local empty_lines_needed = 0 - local cursor_line = 0 - - if #items > 0 then - if prompt_position == 'bottom' then - empty_lines_needed = win_height - display_count - cursor_line = empty_lines_needed + M.state.cursor - else - cursor_line = M.state.cursor - end - cursor_line = math.max(1, math.min(cursor_line, win_height)) + local view = viewport.calculate(#items, M.state.cursor, win_height, prompt_position) + local empty_lines_needed = view.padding + local cursor_line = view.cursor_line + local first_index = view.reverse and view.last or view.first + local last_index = view.reverse and view.first or view.last + local index_step = view.reverse and -1 or 1 + + local function visible_line(item_index) + local offset = view.reverse and (view.last - item_index) or (item_index - view.first) + return empty_lines_needed + offset + 1 end local lines = {} @@ -331,8 +292,8 @@ function M.render_list() end -- Format each git file line - for i = 1, display_count do - local item = items[i] + for item_index = first_index, last_index, index_step do + local item = items[item_index] local border_char = git_utils.get_border_char(item.git_status) local line = '' @@ -362,16 +323,22 @@ function M.render_list() vim.api.nvim_buf_add_highlight(M.state.list_buf, M.state.ns_id, M.state.config.hl.cursor, cursor_line - 1, 0, -1) -- Apply git status highlights for each visible item - for i = 1, display_count do - local item = items[i] - local line_idx = empty_lines_needed + i + for item_index = first_index, last_index, index_step do + local item = items[item_index] + local line_idx = visible_line(item_index) local is_cursor_line = line_idx == cursor_line local border_hl = is_cursor_line and git_utils.get_border_highlight_selected(item.git_status) or git_utils.get_border_highlight(item.git_status) + if M.state.selected[item.relative_path] then + vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, { + sign_text = '▊', + sign_hl_group = 'Visual', + priority = 1100, + }) -- Add sign for git status - if git_utils.should_show_border(item.git_status) then + elseif git_utils.should_show_border(item.git_status) then vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, { sign_text = git_utils.get_border_char(item.git_status), sign_hl_group = border_hl, @@ -394,7 +361,11 @@ function M.update_results() return end - M.state.items = M.get_git_status_files(git_root) + if M.state.source == 'tracked' then + M.state.items = M.get_tracked_files(git_root) + else + M.state.items = M.get_git_status_files(git_root) + end M.filter_results() M.render_list() M.update_status() @@ -403,20 +374,37 @@ end function M.filter_results() if not M.state.active then return end - local query = M.state.query:lower() - - if query == '' then - M.state.filtered_items = M.state.items - else - M.state.filtered_items = {} - for _, item in ipairs(M.state.items) do - if item.relative_path:lower():find(query, 1, true) then table.insert(M.state.filtered_items, item) end - end - end + M.state.filtered_items = matcher.filter( + M.state.items, + M.state.query, + function(item) return item.relative_path or '' end + ) M.state.cursor = 1 end +function M.get_git_diff(item) + if M.state.source ~= 'status' or item.git_status == 'untracked' then return nil end + + local git_root = M.get_git_root() + if not git_root then return nil end + return git_source.diff(git_root, item.relative_path) +end + +local function render_git_diff(item) + local diff = M.get_git_diff(item) + if not diff then return false end + + local lines = vim.split(diff, '\n', { plain = true, trimempty = true }) + if #lines == 0 then return false end + + vim.api.nvim_buf_set_option(M.state.preview_buf, 'modifiable', true) + vim.api.nvim_buf_set_lines(M.state.preview_buf, 0, -1, false, lines) + vim.api.nvim_buf_set_option(M.state.preview_buf, 'filetype', 'diff') + vim.api.nvim_buf_set_option(M.state.preview_buf, 'modifiable', false) + return true +end + function M.update_preview() if not M.is_preview_enabled() then return end if not M.state.active then return end @@ -448,6 +436,7 @@ function M.update_preview() }) preview.set_preview_window(M.state.preview_win) + if render_git_diff(item) then return end preview.preview(item.path, M.state.preview_buf) end @@ -485,7 +474,7 @@ function M.move_up() if not M.state.active then return end if #M.state.filtered_items == 0 then return end - M.state.cursor = math.max(M.state.cursor - 1, 1) + M.state.cursor = viewport.move(M.state.cursor, #M.state.filtered_items, 'up', get_prompt_position()) M.render_list() M.update_preview() end @@ -494,7 +483,7 @@ function M.move_down() if not M.state.active then return end if #M.state.filtered_items == 0 then return end - M.state.cursor = math.min(M.state.cursor + 1, #M.state.filtered_items) + M.state.cursor = viewport.move(M.state.cursor, #M.state.filtered_items, 'down', get_prompt_position()) M.render_list() M.update_preview() end @@ -511,6 +500,55 @@ function M.scroll_preview_down() preview.scroll(math.floor(win_height / 2)) end +local function current_item() + if #M.state.filtered_items == 0 or M.state.cursor > #M.state.filtered_items then return nil end + return M.state.filtered_items[M.state.cursor] +end + +local function chosen_items() + return selection.collect( + M.state.items, + M.state.selected, + current_item(), + function(item) return item.relative_path end + ) +end + +function M.toggle_selection() + local item = current_item() + if not item then return end + selection.toggle(M.state.selected, item.relative_path) + M.render_list() +end + +function M.send_to_quickfix() + local items = chosen_items() + if #items == 0 then return end + + local quickfix = {} + for _, item in ipairs(items) do + table.insert(quickfix, { + filename = item.path, + lnum = 1, + col = 1, + text = item.relative_path, + }) + end + + M.close() + vim.fn.setqflist({}, ' ', { title = 'FFF+ Git Files', items = quickfix }) + vim.cmd('copen') +end + +function M.paste_selection() + local items = chosen_items() + if #items == 0 then return end + local origin_win = M.state.origin_win + M.close() + if origin_win and vim.api.nvim_win_is_valid(origin_win) then vim.api.nvim_set_current_win(origin_win) end + selection.put(items, function(item) return item.relative_path end) +end + function M.select(action) if not M.state.active then return end @@ -587,6 +625,9 @@ function M.close() M.state.query = '' M.state.ns_id = nil M.state.last_preview_file = nil + M.state.source = 'status' + M.state.selected = {} + M.state.origin_win = nil pcall(vim.api.nvim_del_augroup_by_name, 'fff_plus_git_files_picker_focus') end @@ -618,6 +659,10 @@ function M.setup_keymaps() vim.keymap.set('i', keymaps.preview_scroll_down, M.scroll_preview_down, input_opts) end + vim.keymap.set('i', keymaps.toggle_select or '', M.toggle_selection, input_opts) + vim.keymap.set('i', keymaps.send_to_quickfix or '', M.send_to_quickfix, input_opts) + vim.keymap.set('i', keymaps.paste or '', M.paste_selection, input_opts) + -- Handle input changes using buf_attach vim.api.nvim_buf_attach(M.state.input_buf, false, { on_lines = function() @@ -631,6 +676,11 @@ end function M.open(opts) if M.state.active then return end + M.state.origin_win = vim.api.nvim_get_current_win() + + opts = opts or {} + local source = opts.source or 'status' + local git_root = M.get_git_root() if not git_root then vim.notify('Not in a git repository', vim.log.levels.WARN) @@ -638,15 +688,20 @@ function M.open(opts) end local config = conf.get() - local merged_config = vim.tbl_deep_extend('force', config or {}, opts or {}) + local merged_config = vim.tbl_deep_extend('force', config or {}, opts) - if merged_config.title == nil then merged_config.title = 'Git Files' end + if merged_config.title == nil then merged_config.title = source == 'tracked' and 'Git Files' or 'Git Status' end if merged_config.prompt == nil then merged_config.prompt = '🦆 ' end M.state.config = merged_config + M.state.source = source M.state.active = true - M.state.items = M.get_git_status_files(git_root) + if source == 'tracked' then + M.state.items = M.get_tracked_files(git_root) + else + M.state.items = M.get_git_status_files(git_root) + end M.state.filtered_items = M.state.items if not M.create_ui() then @@ -655,13 +710,7 @@ function M.open(opts) return end - -- Set initial cursor position - local prompt_position = get_prompt_position() - if prompt_position == 'bottom' then - M.state.cursor = #M.state.filtered_items > 0 and #M.state.filtered_items or 1 - else - M.state.cursor = 1 - end + M.state.cursor = 1 M.render_list() M.update_preview() diff --git a/lua/fff_plus/selection.lua b/lua/fff_plus/selection.lua new file mode 100644 index 00000000..b4e14a22 --- /dev/null +++ b/lua/fff_plus/selection.lua @@ -0,0 +1,31 @@ +local M = {} + +function M.toggle(selected, key) + if selected[key] then + selected[key] = nil + return false + end + + selected[key] = true + return true +end + +function M.collect(items, selected, current, get_key) + local chosen = {} + for _, item in ipairs(items) do + if selected[get_key(item)] then table.insert(chosen, item) end + end + + if #chosen == 0 and current then table.insert(chosen, current) end + return chosen +end + +function M.put(items, get_text) + local lines = {} + for _, item in ipairs(items) do + table.insert(lines, get_text(item)) + end + if #lines > 0 then vim.api.nvim_put(lines, 'l', true, true) end +end + +return M diff --git a/lua/fff_plus/viewport.lua b/lua/fff_plus/viewport.lua new file mode 100644 index 00000000..ada977f8 --- /dev/null +++ b/lua/fff_plus/viewport.lua @@ -0,0 +1,39 @@ +local M = {} + +function M.calculate(total, cursor, height, prompt_position) + height = math.max(1, height or 1) + local reverse = prompt_position == 'bottom' + if total <= 0 then + return { + first = 1, + last = 0, + padding = reverse and height or 0, + cursor_line = 0, + reverse = reverse, + } + end + + cursor = math.max(1, math.min(cursor or 1, total)) + local first = math.max(1, cursor - height + 1) + local last = math.min(total, first + height - 1) + local visible = last - first + 1 + local padding = reverse and math.max(0, height - visible) or 0 + local cursor_line = reverse and (padding + last - cursor + 1) or (cursor - first + 1) + + return { + first = first, + last = last, + padding = padding, + cursor_line = cursor_line, + reverse = reverse, + } +end + +function M.move(cursor, total, direction, prompt_position) + if total <= 0 then return 1 end + local delta = direction == 'up' and -1 or 1 + if prompt_position == 'bottom' then delta = -delta end + return math.max(1, math.min(cursor + delta, total)) +end + +return M diff --git a/tests/test_fff_plus_extension.lua b/tests/test_fff_plus_extension.lua index acf12bf1..229ac972 100644 --- a/tests/test_fff_plus_extension.lua +++ b/tests/test_fff_plus_extension.lua @@ -56,6 +56,183 @@ local function test_picker_modules_load() print('✓ fff_plus picker modules load correctly') end +local function test_fuzzy_matcher() + print('Testing fuzzy matcher...') + local matcher = require('fff_plus.matcher') + + assert(matcher.score('buffer_test_module.lua', 'btm'), 'subsequence queries should match') + assert(matcher.score('buffer.lua', 'buf') > matcher.score('big_utility_file.lua', 'buf')) + + local items = { + { name = 'big_utility_file.lua' }, + { name = 'buffer.lua' }, + { name = 'buffers.lua' }, + } + local matches = matcher.filter(items, 'buf', function(item) return item.name end) + + assert(#matches == 3, 'all fuzzy matches should be returned') + assert(matches[1].name == 'buffer.lua', 'stronger and shorter matches should rank first') + + local buffers = require('fff_plus.pickers.buffers') + local buffer_matches = buffers.filter_buffers({ { display_name = 'buffer_test_module.lua' } }, 'btm') + assert(#buffer_matches == 1, 'buffer picker should use fuzzy subsequence matching') + + local colors = require('fff_plus.pickers.colors') + local color_matches = colors.filter_colorschemes({ { name = 'tokyonight-moon' } }, 'tnm') + assert(#color_matches == 1, 'colors picker should use fuzzy subsequence matching') + + local git_files = require('fff_plus.pickers.git_files') + git_files.state.active = true + git_files.state.items = { { relative_path = 'lua/fff_plus/matcher.lua' } } + git_files.state.query = 'fpm' + git_files.filter_results() + assert(#git_files.state.filtered_items == 1, 'Git picker should use fuzzy subsequence matching') + git_files.state.active = false + print('✓ fuzzy matcher ranks subsequence matches') +end + +local function test_viewport_calculation() + print('Testing picker viewport...') + local viewport = require('fff_plus.viewport') + + local first_page = viewport.calculate(3, 1, 5, 'bottom') + assert(first_page.first == 1 and first_page.last == 3) + assert(first_page.padding == 2 and first_page.cursor_line == 5) + assert(first_page.reverse == true, 'bottom prompt should render best matches nearest the prompt') + + local scrolled = viewport.calculate(20, 12, 5, 'bottom') + assert(scrolled.first == 8 and scrolled.last == 12) + assert(scrolled.padding == 0 and scrolled.cursor_line == 1) + + local top = viewport.calculate(20, 7, 5, 'top') + assert(top.first == 3 and top.last == 7) + assert(top.padding == 0 and top.cursor_line == 5) + + assert(viewport.move(1, 20, 'up', 'bottom') == 2) + assert(viewport.move(2, 20, 'down', 'bottom') == 1) + assert(viewport.move(2, 20, 'up', 'top') == 1) + assert(viewport.move(2, 20, 'down', 'top') == 3) + print('✓ picker viewport keeps the logical cursor visible') +end + +local function test_picker_layout() + print('Testing picker layout...') + local layout = require('fff_plus.layout') + + local floating = layout.frame(120, 40, { width = 0.8, height = 0.8 }, false) + assert(floating.width == 96 and floating.height == 32) + assert(floating.col == 12 and floating.row == 4) + + local fullscreen = layout.frame(120, 40, {}, true) + assert(fullscreen.width == 116 and fullscreen.height == 36) + assert(fullscreen.col == 1 and fullscreen.row == 0) + print('✓ picker layout resolves floating and fullscreen frames') +end + +local function test_git_sources() + print('Testing Git sources...') + local git_source = require('fff_plus.git_source') + + local tracked = git_source.parse_tracked('README.md\0lua/file with spaces.lua\0') + assert(#tracked == 2, 'tracked parser should preserve every NUL-delimited path') + assert(tracked[2] == 'lua/file with spaces.lua', 'tracked parser should preserve spaces') + + local status = + git_source.parse_status(' M lua/modified file.lua\0R lua/new name.lua\0lua/old name.lua\0?? lua/untracked.lua\0') + assert(#status == 3, 'status parser should return modified, renamed, and untracked files') + assert(status[1].git_status == 'modified') + assert(status[1].relative_path == 'lua/modified file.lua') + assert(status[2].git_status == 'renamed') + assert(status[2].relative_path == 'lua/new name.lua') + assert(status[2].old_path == 'lua/old name.lua') + assert(status[3].git_status == 'untracked') + + local original_run = git_source.run + local captured + git_source.run = function(_, args) + captured = args + return 'diff --git a/file b/file\n' + end + local diff = git_source.diff('/repo', 'lua/file with spaces.lua') + git_source.run = original_run + assert(diff:find('diff %-%-git'), 'Git diff should return command output') + assert(captured[1] == 'diff' and captured[#captured] == 'lua/file with spaces.lua') + print('✓ Git sources preserve paths and rename records') +end + +local function test_picker_selection() + print('Testing picker selection...') + local selection = require('fff_plus.selection') + local selected = {} + + assert(selection.toggle(selected, 'b.lua') == true) + assert(selected['b.lua'] == true, 'toggle should select a new key') + assert(selection.toggle(selected, 'b.lua') == false) + assert(selected['b.lua'] == nil, 'toggle should clear an existing key') + + selected['b.lua'] = true + selected['a.lua'] = true + local items = { { path = 'a.lua' }, { path = 'b.lua' }, { path = 'c.lua' } } + local chosen = selection.collect(items, selected, items[3], function(item) return item.path end) + assert(#chosen == 2 and chosen[1].path == 'a.lua' and chosen[2].path == 'b.lua') + + local fallback = selection.collect(items, {}, items[3], function(item) return item.path end) + assert(#fallback == 1 and fallback[1].path == 'c.lua', 'current item should be used with no selection') + + local current_buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(current_buf, 0, -1, false, { 'anchor' }) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + selection.put({ { path = 'a.lua' }, { path = 'b.lua' } }, function(item) return item.path end) + local pasted = vim.api.nvim_buf_get_lines(current_buf, 0, -1, false) + assert(pasted[2] == 'a.lua' and pasted[3] == 'b.lua', 'paste should insert selected paths') + print('✓ picker selection preserves item order and current-item fallback') +end + +local function test_picker_actions() + print('Testing picker actions...') + local buffers = require('fff_plus.pickers.buffers') + local bufnr = vim.api.nvim_get_current_buf() + local buffer_item = { + bufnr = bufnr, + line = 1, + display_name = 'current buffer', + path = vim.api.nvim_buf_get_name(bufnr), + } + + buffers.state.active = true + buffers.state.items = { buffer_item } + buffers.state.filtered_items = { buffer_item } + buffers.state.cursor = 1 + buffers.state.selected = { [bufnr] = true } + buffers.state.config = { preview = { enabled = false }, jump_to_existing = true } + assert(buffers.find_existing_window(bufnr) == vim.api.nvim_get_current_win()) + + local original_buffer_close = buffers.close + buffers.close = function() buffers.state.active = false end + buffers.send_to_quickfix() + buffers.close = original_buffer_close + local buffer_qf = vim.fn.getqflist({ title = 1, items = 1 }) + assert(buffer_qf.title == 'FFF+ Buffers' and #buffer_qf.items == 1) + vim.cmd('cclose') + + local git_files = require('fff_plus.pickers.git_files') + local git_source = require('fff_plus.git_source') + local original_root = git_files.get_git_root + local original_diff = git_source.diff + git_files.get_git_root = function() return '/repo' end + git_source.diff = function(root, path) + assert(root == '/repo' and path == 'README.md') + return 'diff --git a/README.md b/README.md' + end + git_files.state.source = 'status' + assert(git_files.get_git_diff({ relative_path = 'README.md', git_status = 'modified' })) + git_files.state.source = 'tracked' + assert(git_files.get_git_diff({ relative_path = 'README.md', git_status = 'clean' }) == nil) + git_files.get_git_root = original_root + git_source.diff = original_diff + print('✓ picker actions populate quickfix, jump windows, and select diff previews') +end + local function test_commands_register() print('Testing fff_plus commands register...') require('fff_plus').setup() @@ -63,6 +240,31 @@ local function test_commands_register() assert(vim.fn.exists(':FFFPlusBuffers') == 2, 'FFFPlusBuffers command should exist') assert(vim.fn.exists(':FFFPlusColors') == 2, 'FFFPlusColors command should exist') assert(vim.fn.exists(':FFFPlusGFiles') == 2, 'FFFPlusGFiles command should exist') + assert(vim.fn.exists(':FFFPlusGitFiles') == 2, 'FFFPlusGitFiles command should exist') + assert(vim.fn.exists(':FFFPlusGitStatus') == 2, 'FFFPlusGitStatus command should exist') + assert(type(require('fff_plus').tracked_files) == 'function', 'tracked_files API should exist') + assert(type(require('fff_plus').git_status) == 'function', 'git_status API should exist') + + local commands = vim.api.nvim_get_commands({ builtin = false }) + assert(commands.FFFPlusBuffers.bang, 'FFFPlusBuffers should support fullscreen bang') + assert(commands.FFFPlusColors.bang, 'FFFPlusColors should support fullscreen bang') + assert(commands.FFFPlusGitFiles.bang, 'FFFPlusGitFiles should support fullscreen bang') + assert(commands.FFFPlusGitStatus.bang, 'FFFPlusGitStatus should support fullscreen bang') + + require('fff_plus').setup({ legacy_commands = true }) + local plus = require('fff_plus') + local original_open = plus.open + local opened + plus.open = function(name, opts) + opened = { name = name, opts = opts or {} } + return true + end + + vim.cmd('GFiles') + assert(opened.name == 'git_files' and opened.opts.source == 'tracked', ':GFiles should dispatch tracked files') + vim.cmd('FFFPlusGitStatus!') + assert(opened.name == 'git_files' and opened.opts.fullscreen == true, 'bang should propagate fullscreen') + plus.open = original_open print('✓ fff_plus commands register correctly') end @@ -72,6 +274,12 @@ local function run_tests() local tests = { test_extension_module_loads, test_picker_modules_load, + test_fuzzy_matcher, + test_viewport_calculation, + test_picker_layout, + test_git_sources, + test_picker_selection, + test_picker_actions, test_commands_register, }