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

View file

@ -1,6 +1,7 @@
local M = {} local M = {}
---@class forge.Config ---@class forge.Config
---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto'
---@field ci forge.CIConfig ---@field ci forge.CIConfig
---@field sources table<string, forge.SourceConfig> ---@field sources table<string, forge.SourceConfig>
---@field keys forge.KeysConfig|false ---@field keys forge.KeysConfig|false
@ -83,6 +84,7 @@ local M = {}
---@type forge.Config ---@type forge.Config
local DEFAULTS = { local DEFAULTS = {
picker = 'auto',
ci = { lines = 10000 }, ci = { lines = 10000 },
sources = {}, sources = {},
keys = { keys = {
@ -538,15 +540,25 @@ local function extract_author(entry, field)
return tostring(v or '') return tostring(v or '')
end end
local function hl(group, text) ---@param secs integer
local utils = require('fzf-lua.utils') ---@return string
return utils.ansi_from_hl(group, text) local function format_duration(secs)
if secs < 0 then
secs = 0
end
if secs >= 3600 then
return ('%dh%dm'):format(math.floor(secs / 3600), math.floor(secs % 3600 / 60))
end
if secs >= 60 then
return ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
end
return ('%ds'):format(secs)
end end
---@param entry table ---@param entry table
---@param fields table ---@param fields table
---@param show_state boolean ---@param show_state boolean
---@return string ---@return forge.Segment[]
function M.format_pr(entry, fields, show_state) function M.format_pr(entry, fields, show_state)
local display = M.config().display local display = M.config().display
local icons = display.icons local icons = display.icons
@ -555,7 +567,7 @@ function M.format_pr(entry, fields, show_state)
local title = entry[fields.title] or '' local title = entry[fields.title] or ''
local author = extract_author(entry, fields.author) local author = extract_author(entry, fields.author)
local age = relative_time(entry[fields.created_at]) local age = relative_time(entry[fields.created_at])
local prefix = '' local segments = {}
if show_state then if show_state then
local state = (entry[fields.state] or ''):lower() local state = (entry[fields.state] or ''):lower()
local icon, group local icon, group
@ -566,20 +578,22 @@ function M.format_pr(entry, fields, show_state)
else else
icon, group = icons.closed, 'ForgeClosed' icon, group = icons.closed, 'ForgeClosed'
end end
prefix = hl(group, icon) .. ' ' table.insert(segments, { icon, group })
table.insert(segments, { ' ' })
end end
return prefix table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
.. hl('ForgeNumber', ('#%-5s'):format(num)) table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
.. ' ' table.insert(segments, {
.. pad_or_truncate(title, widths.title) pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
.. ' ' 'ForgeDim',
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age)) })
return segments
end end
---@param entry table ---@param entry table
---@param fields table ---@param fields table
---@param show_state boolean ---@param show_state boolean
---@return string ---@return forge.Segment[]
function M.format_issue(entry, fields, show_state) function M.format_issue(entry, fields, show_state)
local display = M.config().display local display = M.config().display
local icons = display.icons local icons = display.icons
@ -588,7 +602,7 @@ function M.format_issue(entry, fields, show_state)
local title = entry[fields.title] or '' local title = entry[fields.title] or ''
local author = extract_author(entry, fields.author) local author = extract_author(entry, fields.author)
local age = relative_time(entry[fields.created_at]) local age = relative_time(entry[fields.created_at])
local prefix = '' local segments = {}
if show_state then if show_state then
local state = (entry[fields.state] or ''):lower() local state = (entry[fields.state] or ''):lower()
local icon, group local icon, group
@ -597,18 +611,20 @@ function M.format_issue(entry, fields, show_state)
else else
icon, group = icons.closed, 'ForgeClosed' icon, group = icons.closed, 'ForgeClosed'
end end
prefix = hl(group, icon) .. ' ' table.insert(segments, { icon, group })
table.insert(segments, { ' ' })
end end
return prefix table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
.. hl('ForgeNumber', ('#%-5s'):format(num)) table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
.. ' ' table.insert(segments, {
.. pad_or_truncate(title, widths.title) pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
.. ' ' 'ForgeDim',
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age)) })
return segments
end end
---@param check table ---@param check table
---@return string ---@return forge.Segment[]
function M.format_check(check) function M.format_check(check)
local display = M.config().display local display = M.config().display
local icons = display.icons local icons = display.icons
@ -632,23 +648,18 @@ function M.format_check(check)
local ok_s, ts = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt) local ok_s, ts = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt)
local ok_e, te = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt) local ok_e, te = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt)
if ok_s and ok_e and ts > 0 and te > 0 then if ok_s and ok_e and ts > 0 and te > 0 then
local secs = te - ts elapsed = format_duration(te - ts)
if secs >= 60 then
elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
else
elapsed = ('%ds'):format(secs)
end
end end
end end
return hl(group, icon) return {
.. ' ' { icon, group },
.. pad_or_truncate(name, widths.name) { ' ' .. pad_or_truncate(name, widths.name) .. ' ' },
.. ' ' { elapsed, 'ForgeDim' },
.. hl('ForgeDim', elapsed) }
end end
---@param run forge.CIRun ---@param run forge.CIRun
---@return string ---@return forge.Segment[]
function M.format_run(run) function M.format_run(run)
local display = M.config().display local display = M.config().display
local icons = display.icons local icons = display.icons
@ -670,19 +681,18 @@ function M.format_run(run)
local age = relative_time(run.created_at) local age = relative_time(run.created_at)
if run.branch ~= '' then if run.branch ~= '' then
local name_w = widths.name - widths.branch + 10 local name_w = widths.name - widths.branch + 10
return hl(group, icon) return {
.. ' ' { icon, group },
.. pad_or_truncate(run.name, name_w) { ' ' .. pad_or_truncate(run.name, name_w) .. ' ' },
.. ' ' { pad_or_truncate(run.branch, widths.branch), 'ForgeBranch' },
.. hl('ForgeBranch', pad_or_truncate(run.branch, widths.branch)) { ' ' .. ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
.. ' ' }
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
end end
return hl(group, icon) return {
.. ' ' { icon, group },
.. pad_or_truncate(run.name, widths.name) { ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' },
.. ' ' { ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age) }
end end
---@param checks table[] ---@param checks table[]
@ -715,6 +725,9 @@ function M.config()
cfg.keys = false cfg.keys = false
end end
vim.validate('forge.picker', cfg.picker, function(v)
return v == 'auto' or v == 'fzf-lua' or v == 'telescope' or v == 'snacks'
end, "'auto', 'fzf-lua', 'telescope', or 'snacks'")
vim.validate('forge.sources', cfg.sources, 'table') vim.validate('forge.sources', cfg.sources, 'table')
vim.validate('forge.keys', cfg.keys, function(v) vim.validate('forge.keys', cfg.keys, function(v)
return v == false or type(v) == 'table' return v == false or type(v) == 'table'

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

File diff suppressed because it is too large Load diff