Last active
September 15, 2025 11:15
-
-
Save suliatis/5d59fcff490dc32b9e877a599559b05f to your computer and use it in GitHub Desktop.
Enhanced file and buffer picker with intelligent highlighting
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- SmartPick - Enhanced file and buffer picker with intelligent highlighting | |
| --- ========================================================================== | |
| --- | |
| --- A unified file/buffer picker for Neovim that combines buffer and file search | |
| --- into one intelligent interface. Built on mini.nvim ecosystem (mini.pick). | |
| --- | |
| --- ## Problem It Solves | |
| --- - Eliminates "picker paralysis" - no more choosing between buffer vs file picker | |
| --- - Shows everything in one list: buffers (by recency) → files (alphabetically) | |
| --- | |
| --- ## Key Features | |
| --- | |
| --- ### 1. Smart Prioritization | |
| --- - Buffers score 2x higher than files | |
| --- - Filename matches score 3x higher than path matches | |
| --- - Example: "ini" → `init.lua` (buffer) > `init.txt` (file) > `admin_interface.rb` (file) | |
| --- | |
| --- ### 2. Visual Distinctions | |
| --- - Buffer items: subtle background highlight (`SmartPickBuffer`) | |
| --- - Directory paths: dimmed (`SmartPickPath`) | |
| --- - Match highlights: emphasized in paths (`SmartPickPathMatch`) | |
| --- - Generic filenames (index.*, init.*, main.*): special path handling | |
| --- | |
| --- ### 3. Data Flow | |
| --- ``` | |
| --- ripgrep files → deduplicate with buffers → combine lists → | |
| --- matchfuzzypos matching → apply score multipliers → sort → display | |
| --- ``` | |
| --- | |
| --- ## Architecture | |
| --- ```lua | |
| --- SmartPick.picker() | |
| --- ├── H.postprocess_items() -- Combine buffers + files | |
| --- ├── H.match_items() -- Fuzzy match with scoring | |
| --- │ └── vim.fn.matchfuzzypos() -- Neovim native fuzzy algorithm | |
| --- └── H.show_items() -- Display with highlights | |
| --- ├── H.apply_path_highlights() | |
| --- └── H.apply_match_highlights_in_path() | |
| --- ``` | |
| --- | |
| --- ## Scoring Logic | |
| --- ```lua | |
| --- base_score = vim.fn.matchfuzzypos(items, query)[3][i] -- Neovim native | |
| --- if is_buffer: score *= 2 -- Higher is better (buffers boosted) | |
| --- if filename_match: score *= 3 -- Strong boost for filename matches | |
| --- ``` | |
| --- | |
| --- ## Usage | |
| --- ```lua | |
| --- require('SmartPick').setup() | |
| --- vim.keymap.set('n', '<leader>f', require('SmartPick').picker) | |
| --- ``` | |
| --- | |
| --- ## Dependencies | |
| --- - mini.pick: Picker UI framework | |
| --- - vim.fn.matchfuzzypos: Neovim native fuzzy matching (0.11+) | |
| --- - ripgrep: Fast file discovery | |
| --- | |
| --- ## Critical Implementation Details | |
| --- | |
| --- ### Metadata Storage | |
| --- Text-based keys in `picker_items.items[text]` with consistent format: | |
| --- ```lua | |
| --- { | |
| --- type = 'buffer'|'file', | |
| --- text = string, | |
| --- is_file = boolean, | |
| --- bufnr = number -- only for buffers | |
| --- } | |
| --- ``` | |
| --- | |
| --- ### Empty Query Handling | |
| --- - Delegates to `MiniPick.default_match()` for empty queries | |
| --- - matchfuzzypos returns empty arrays for empty strings, so we bypass it | |
| --- | |
| --- ### Error Recovery | |
| --- - pcall wrapper around custom matching logic | |
| --- - Falls back to default matching if anything goes wrong | |
| --- - Multiple safety checks for data structure integrity | |
| --- | |
| --- ### Buffer Detection | |
| --- - `is_file` flag distinguishes file buffers from special buffers (terminals, help, etc.) | |
| --- - Only file-type items get path highlighting | |
| --- | |
| --- ## Common Issues & Fixes | |
| --- | |
| --- ### Issue: Picker empty/quits on typing | |
| --- **Cause**: matchfuzzypos returns empty arrays for no match | |
| --- **Fix**: Check `#matched_texts == 0` and handle empty queries properly | |
| --- | |
| --- ### Issue: Wrong items highlighted after filtering | |
| --- **Cause**: Index-based metadata lookup fails when MiniPick filters items | |
| --- **Fix**: Use text-based lookup with `picker_items.items[text]` | |
| --- | |
| --- ### Issue: Buffers not prioritized correctly | |
| --- **Cause**: Metadata not properly attached or score multipliers wrong | |
| --- **Fix**: Ensure consistent metadata format and correct multiplier logic | |
| --- | |
| --- @author Suliatis | |
| --- @license MIT | |
| local SmartPick = {} | |
| local H = {} | |
| -- ============================================================================ | |
| -- PUBLIC API | |
| -- ============================================================================ | |
| --- Initialize SmartPick by creating the highlight namespace | |
| --- @return nil | |
| function SmartPick.setup() | |
| H.ns_id = vim.api.nvim_create_namespace('SmartPick') | |
| end | |
| --- Launch the SmartPick picker interface | |
| --- Combines buffers (by recency) and files (alphabetically) in a unified picker | |
| --- @return nil | |
| function SmartPick.picker() | |
| if _G.MiniPick == nil then | |
| _G.MiniPick = require('mini.pick') | |
| end | |
| local picker_items = { items = {} } | |
| MiniPick.builtin.cli({ | |
| command = { | |
| 'sh', | |
| '-c', | |
| 'rg --files --hidden --glob \'!.git\'; rg --files --no-ignore-vcs --glob \'*.env\'', | |
| }, | |
| postprocess = function(paths) | |
| return H.postprocess_items(paths, picker_items) | |
| end, | |
| }, { | |
| source = { | |
| name = 'Smart Open', | |
| show = function(buf_id, items, query) | |
| H.show_items(buf_id, items, query, picker_items) | |
| end, | |
| choose = MiniPick.default_choose, | |
| match = function(stritems, inds, query) | |
| return H.match_items(stritems, inds, query, picker_items) | |
| end, | |
| }, | |
| }) | |
| end | |
| -- ============================================================================ | |
| -- SECTION: Constants | |
| -- ============================================================================ | |
| H.GENERIC_FILENAMES = { | |
| ['init.lua'] = true, | |
| ['index.html'] = true, | |
| ['index.js'] = true, | |
| ['index.jsx'] = true, | |
| ['index.ts'] = true, | |
| ['index.tsx'] = true, | |
| ['main.js'] = true, | |
| ['main.ts'] = true, | |
| ['app.js'] = true, | |
| ['app.ts'] = true, | |
| ['mod.rs'] = true, | |
| ['lib.rs'] = true, | |
| ['__init__.py'] = true, | |
| } | |
| -- ============================================================================ | |
| -- SECTION: Buffer Management | |
| -- ============================================================================ | |
| function H.get_recent_buffers() | |
| local buffers = vim.fn.getbufinfo({ buflisted = 1 }) | |
| buffers = vim.tbl_filter(function(buf) | |
| local buftype = vim.bo[buf.bufnr].buftype | |
| if buftype == 'quickfix' or buftype == 'prompt' then | |
| return false | |
| end | |
| if buftype == '' then | |
| if buf.name == '' then | |
| return true | |
| end | |
| return vim.fn.filereadable(buf.name) == 1 | |
| else | |
| return true | |
| end | |
| end, buffers) | |
| table.sort(buffers, function(a, b) | |
| return a.lastused > b.lastused | |
| end) | |
| return vim.tbl_map(function(buf) | |
| local buftype = vim.bo[buf.bufnr].buftype | |
| local is_file = buftype == '' | |
| local text = is_file and vim.fn.fnamemodify(buf.name, ':.') or buf.name | |
| return { text = text, bufnr = buf.bufnr, type = 'buffer', is_file = is_file } | |
| end, buffers) | |
| end | |
| -- ============================================================================ | |
| -- SECTION: File Operations | |
| -- ============================================================================ | |
| function H.deduplicate_files(buffers, files) | |
| local seen = {} | |
| for _, buf in ipairs(buffers) do | |
| seen[buf.text] = true | |
| end | |
| local deduplicated = {} | |
| for _, path in ipairs(files) do | |
| if path ~= '' then | |
| path = vim.fn.fnamemodify(path, ':.') | |
| if not seen[path] then | |
| table.insert(deduplicated, path) | |
| end | |
| end | |
| end | |
| table.sort(deduplicated) | |
| return deduplicated | |
| end | |
| -- ============================================================================ | |
| -- SECTION: Item Processing | |
| -- ============================================================================ | |
| function H.get_item_text(item) | |
| return type(item) == 'table' and item.text or item | |
| end | |
| function H.get_filename(path) | |
| return path:match('([^/]+)$') or path | |
| end | |
| function H.postprocess_items(paths, picker_items) | |
| local buffers = H.get_recent_buffers() | |
| local files = H.deduplicate_files(buffers, paths) | |
| picker_items.items = {} | |
| local all_items = {} | |
| for _, buf in ipairs(buffers) do | |
| table.insert(all_items, buf) | |
| picker_items.items[buf.text] = { | |
| type = 'buffer', | |
| text = buf.text, | |
| bufnr = buf.bufnr, | |
| is_file = buf.is_file, | |
| } | |
| end | |
| for _, file in ipairs(files) do | |
| table.insert(all_items, file) | |
| picker_items.items[file] = { | |
| type = 'file', | |
| text = file, | |
| is_file = true, | |
| } | |
| end | |
| return all_items | |
| end | |
| -- ============================================================================ | |
| -- SECTION: Highlighting | |
| -- ============================================================================ | |
| function H.calculate_path_highlight_end(text) | |
| local last_slash = text:match('.*()/') | |
| if not last_slash or last_slash <= 1 then | |
| return nil | |
| end | |
| local filename = text:sub(last_slash + 1) | |
| if H.GENERIC_FILENAMES[filename] then | |
| local path_without_filename = text:sub(1, last_slash - 1) | |
| local second_last_slash = path_without_filename:match('.*()/') | |
| if second_last_slash and second_last_slash > 1 then | |
| return second_last_slash - 1 | |
| end | |
| return nil | |
| else | |
| return last_slash - 1 | |
| end | |
| end | |
| function H.apply_path_highlights(buf_id, line_nr, text, line_content) | |
| local dir_end_pos = H.calculate_path_highlight_end(text) | |
| if not dir_end_pos then | |
| return nil | |
| end | |
| local icon_end = line_content:find(text, 1, true) or 1 | |
| local dir_start = icon_end - 1 | |
| local dir_end = icon_end + dir_end_pos - 1 | |
| vim.api.nvim_buf_set_extmark(buf_id, H.ns_id, line_nr, dir_start, { | |
| end_col = dir_end, | |
| hl_group = 'SmartPickPath', | |
| priority = 50, | |
| }) | |
| return { icon_end = icon_end, dir_end_pos = dir_end_pos, dir_start = dir_start, dir_end = dir_end } | |
| end | |
| function H.apply_match_highlights_in_path(buf_id, line_nr, text, query, path_info) | |
| if not query or #query == 0 or not path_info then | |
| return | |
| end | |
| local query_str = table.concat(query):lower() | |
| local path_portion = text:sub(1, path_info.dir_end_pos):lower() | |
| local search_start = 1 | |
| while true do | |
| local match_start, match_end = path_portion:find(query_str, search_start, true) | |
| if not match_start then | |
| break | |
| end | |
| local abs_start = path_info.icon_end - 1 + match_start - 1 | |
| local abs_end = path_info.icon_end - 1 + match_end | |
| if abs_start >= path_info.dir_start and abs_end <= path_info.dir_end then | |
| vim.api.nvim_buf_set_extmark(buf_id, H.ns_id, line_nr, abs_start, { | |
| end_col = abs_end, | |
| hl_group = 'SmartPickPathMatch', | |
| priority = 200, | |
| }) | |
| end | |
| search_start = match_end + 1 | |
| end | |
| end | |
| function H.show_items(buf_id, items, query, picker_items) | |
| local MiniPick = require('mini.pick') | |
| MiniPick.default_show(buf_id, items, query, { show_icons = true }) | |
| for i, item in ipairs(items) do | |
| local line_nr = i - 1 | |
| local text = H.get_item_text(item) | |
| local item_meta = picker_items.items[text] | |
| if item_meta and item_meta.type == 'buffer' and item_meta.is_file then | |
| local line_len = vim.api.nvim_buf_get_lines(buf_id, line_nr, line_nr + 1, false)[1]:len() | |
| vim.api.nvim_buf_set_extmark(buf_id, H.ns_id, line_nr, 0, { | |
| end_col = line_len, | |
| hl_group = 'SmartPickBuffer', | |
| priority = 10, | |
| }) | |
| end | |
| local is_file_path = (item_meta and item_meta.type == 'file') | |
| or (item_meta and item_meta.type == 'buffer' and item_meta.is_file) | |
| if is_file_path then | |
| local line_content = vim.api.nvim_buf_get_lines(buf_id, line_nr, line_nr + 1, false)[1] or '' | |
| local path_info = H.apply_path_highlights(buf_id, line_nr, text, line_content) | |
| H.apply_match_highlights_in_path(buf_id, line_nr, text, query, path_info) | |
| end | |
| end | |
| end | |
| -- ============================================================================ | |
| -- SECTION: Matching | |
| -- ============================================================================ | |
| function H.match_items(stritems, inds, query, picker_items) | |
| local MiniPick = require('mini.pick') | |
| -- Safety checks | |
| if #stritems == 0 or #inds == 0 then | |
| return inds | |
| end | |
| -- Empty query: return all items using default behavior | |
| if not query or query == '' then | |
| return MiniPick.default_match(stritems, inds, query, { sync = true }) | |
| end | |
| -- Fallback to default matching if picker_items is not properly structured | |
| if not picker_items or not picker_items.items then | |
| return MiniPick.default_match(stritems, inds, query, { sync = true }) | |
| end | |
| -- Wrap in pcall for error safety | |
| local ok, result = pcall(function() | |
| -- Get items to match (only those in inds) | |
| local items_to_match = {} | |
| local idx_map = {} -- Map result index to original index | |
| for _, idx in ipairs(inds) do | |
| table.insert(items_to_match, stritems[idx]) | |
| idx_map[#items_to_match] = idx | |
| end | |
| -- Use matchfuzzypos for fuzzy matching | |
| local match_result = vim.fn.matchfuzzypos(items_to_match, query) | |
| local matched_texts = match_result[1] | |
| local scores = match_result[3] | |
| if #matched_texts == 0 then | |
| return {} | |
| end | |
| -- Build scored items with our custom boosts | |
| local scored_items = {} | |
| for i, text in ipairs(matched_texts) do | |
| local orig_idx = nil | |
| -- Find original index by matching text | |
| for j, item in ipairs(items_to_match) do | |
| if item == text then | |
| orig_idx = idx_map[j] | |
| -- Remove from idx_map to handle duplicates | |
| idx_map[j] = nil | |
| break | |
| end | |
| end | |
| if orig_idx then | |
| local score = scores[i] | |
| local meta = picker_items.items[text] | |
| -- Apply boosts (higher score is better for matchfuzzypos) | |
| if meta and meta.type == 'buffer' then | |
| score = score * 2 -- Double score for buffers | |
| end | |
| -- Check filename match boost | |
| local filename = H.get_filename(text) | |
| local filename_result = vim.fn.matchfuzzypos({ filename }, query) | |
| if #filename_result[1] > 0 then | |
| score = score * 3 -- Triple score for filename matches | |
| end | |
| table.insert(scored_items, { idx = orig_idx, score = score }) | |
| end | |
| end | |
| -- Sort by score (higher is better for matchfuzzypos) | |
| table.sort(scored_items, function(a, b) | |
| if math.abs(a.score - b.score) < 0.001 then | |
| -- Tiebreaker: original order | |
| return a.idx < b.idx | |
| end | |
| return a.score > b.score -- Higher score first | |
| end) | |
| -- Return sorted indices | |
| return vim.tbl_map(function(item) | |
| return item.idx | |
| end, scored_items) | |
| end) | |
| if ok then | |
| return result | |
| else | |
| -- Fallback to default matching if anything goes wrong | |
| return MiniPick.default_match(stritems, inds, query, { sync = true }) | |
| end | |
| end | |
| return SmartPick |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment