feat(picker): add multi-backend picker abstraction

Problem: all pickers were tightly coupled to fzf-lua via ANSI strings
and fzf-specific action tables, making it impossible to use telescope
or snacks.nvim.

Solution: introduce `forge.picker` dispatcher with `fzf`, `telescope`,
and `snacks` backends. Format functions now return `forge.Segment[]`
instead of ANSI strings. `pickers.lua` builds backend-agnostic
`forge.PickerEntry[]` and delegates to `forge.picker.pick()`. Backend
auto-detection tries fzf-lua, snacks, telescope in order. Commits,
branches, and worktree pickers remain fzf-only with graceful fallback.
This commit is contained in:
Barrett Ruth 2026-03-28 17:48:24 -04:00
parent 354c5000c0
commit fa7cab89af
No known key found for this signature in database
GPG key ID: A6C96C9349D2FC81
6 changed files with 826 additions and 599 deletions

76
lua/forge/picker/fzf.lua Normal file
View file

@ -0,0 +1,76 @@
local M = {}
local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '')
:gsub('%-%-bind=[^%s]+', '')
:gsub('%-%-color=[^%s]+', '')
---@param key string
---@return string
local function to_fzf_key(key)
if key == '<cr>' then
return 'default'
end
local result = key:gsub('<c%-(%a)>', function(ch)
return 'ctrl-' .. ch:lower()
end)
return result
end
---@param segments forge.Segment[]
---@return string
local function render(segments)
local utils = require('fzf-lua.utils')
local parts = {}
for _, seg in ipairs(segments) do
if seg[2] then
table.insert(parts, utils.ansi_from_hl(seg[2], seg[1]))
else
table.insert(parts, seg[1])
end
end
return table.concat(parts)
end
---@param opts forge.PickerOpts
function M.pick(opts)
local cfg = require('forge').config()
local keys = cfg.keys
if keys == false then
keys = {}
end
local bindings = keys[opts.picker_name] or {}
local lines = {}
for i, entry in ipairs(opts.entries) do
lines[i] = ('%d\t%s'):format(i, render(entry.display))
end
local fzf_actions = {}
for _, def in ipairs(opts.actions) do
local key = def.name == 'default' and '<cr>' or bindings[def.name]
if key then
fzf_actions[to_fzf_key(key)] = function(selected)
if not selected[1] then
def.fn(nil)
return
end
local idx = tonumber(selected[1]:match('^(%d+)'))
def.fn(idx and opts.entries[idx] or nil)
end
end
end
require('fzf-lua').fzf_exec(lines, {
fzf_args = fzf_args,
prompt = opts.prompt or '',
fzf_opts = {
['--ansi'] = '',
['--no-multi'] = '',
['--with-nth'] = '2..',
['--delimiter'] = '\t',
},
actions = fzf_actions,
})
end
return M

80
lua/forge/picker/init.lua Normal file
View file

@ -0,0 +1,80 @@
local M = {}
---@alias forge.Segment {[1]: string, [2]: string?}
---@class forge.PickerEntry
---@field display forge.Segment[]
---@field value any
---@field ordinal string?
---@class forge.PickerActionDef
---@field name string
---@field fn fun(entry: forge.PickerEntry?)
---@class forge.PickerOpts
---@field prompt string?
---@field entries forge.PickerEntry[]
---@field actions forge.PickerActionDef[]
---@field picker_name string
---@type table<string, string>
local backends = {
['fzf-lua'] = 'forge.picker.fzf',
telescope = 'forge.picker.telescope',
snacks = 'forge.picker.snacks',
}
---@return string
local function detect()
local cfg = require('forge').config()
local name = cfg.picker or 'auto'
if name ~= 'auto' then
return name
end
if pcall(require, 'fzf-lua') then
return 'fzf-lua'
end
if pcall(require, 'snacks') then
return 'snacks'
end
if pcall(require, 'telescope') then
return 'telescope'
end
return 'fzf-lua'
end
---@param entry forge.PickerEntry
---@return string
function M.ordinal(entry)
if entry.ordinal then
return entry.ordinal
end
local parts = {}
for _, seg in ipairs(entry.display) do
table.insert(parts, seg[1])
end
return table.concat(parts)
end
---@return string
function M.backend()
return detect()
end
---@param opts forge.PickerOpts
function M.pick(opts)
local name = detect()
local mod_path = backends[name]
if not mod_path then
vim.notify('[forge]: unknown picker backend: ' .. name, vim.log.levels.ERROR)
return
end
local ok, backend = pcall(require, mod_path)
if not ok then
vim.notify('[forge]: picker backend ' .. name .. ' not available', vim.log.levels.ERROR)
return
end
backend.pick(opts)
end
return M

View file

@ -0,0 +1,64 @@
local M = {}
---@param opts forge.PickerOpts
function M.pick(opts)
local Snacks = require('snacks')
local picker_mod = require('forge.picker')
local cfg = require('forge').config()
local keys = cfg.keys
if keys == false then
keys = {}
end
local bindings = keys[opts.picker_name] or {}
local items = {}
for i, entry in ipairs(opts.entries) do
items[i] = {
idx = i,
text = picker_mod.ordinal(entry),
value = entry,
}
end
local snacks_actions = {}
local input_keys = {}
local list_keys = {}
for _, def in ipairs(opts.actions) do
local key = def.name == 'default' and '<cr>' or bindings[def.name]
if key then
local action_name = 'forge_' .. def.name
snacks_actions[action_name] = function(picker)
local item = picker:current()
picker:close()
def.fn(item and item.value or nil)
end
if key == '<cr>' then
snacks_actions['confirm'] = snacks_actions[action_name]
else
-- selene: allow(mixed_table)
input_keys[key] = { action_name, mode = { 'i', 'n' } }
list_keys[key] = action_name
end
end
end
Snacks.picker({
items = items,
prompt = opts.prompt,
format = function(item)
local ret = {}
for _, seg in ipairs(item.value.display) do
table.insert(ret, { seg[1], seg[2] or 'Normal' })
end
return ret
end,
actions = snacks_actions,
win = {
input = { keys = input_keys },
list = { keys = list_keys },
},
})
end
return M

View file

@ -0,0 +1,69 @@
local M = {}
---@param opts forge.PickerOpts
function M.pick(opts)
local pickers = require('telescope.pickers')
local finders = require('telescope.finders')
local conf = require('telescope.config').values
local actions = require('telescope.actions')
local action_state = require('telescope.actions.state')
local picker_mod = require('forge.picker')
local cfg = require('forge').config()
local keys = cfg.keys
if keys == false then
keys = {}
end
local bindings = keys[opts.picker_name] or {}
local finder = finders.new_table({
results = opts.entries,
entry_maker = function(entry)
return {
value = entry,
ordinal = picker_mod.ordinal(entry),
display = function(tbl)
local text = ''
local hl_list = {}
for _, seg in ipairs(tbl.value.display) do
local start = #text
text = text .. seg[1]
if seg[2] then
table.insert(hl_list, { { start, #text }, seg[2] })
end
end
return text, hl_list
end,
}
end,
})
pickers
.new({}, {
prompt_title = (opts.prompt or ''):gsub('[>%s]+$', ''),
finder = finder,
sorter = conf.generic_sorter({}),
attach_mappings = function(prompt_bufnr, map)
for _, def in ipairs(opts.actions) do
local key = def.name == 'default' and '<cr>' or bindings[def.name]
if key then
local function action_fn()
local entry = action_state.get_selected_entry()
actions.close(prompt_bufnr)
def.fn(entry and entry.value or nil)
end
if key == '<cr>' then
actions.select_default:replace(action_fn)
else
map('i', key, action_fn)
map('n', key, action_fn)
end
end
end
return true
end,
})
:find()
end
return M