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 {
return hl(group, icon) { 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

View file

@ -1,5 +1,7 @@
local M = {} local M = {}
local picker = require('forge.picker')
---@param result { code: integer, stdout: string?, stderr: string? } ---@param result { code: integer, stdout: string?, stderr: string? }
---@param fallback string ---@param fallback string
---@return string ---@return string
@ -15,36 +17,6 @@ local function cmd_error(result, fallback)
return msg return msg
end end
local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '')
:gsub('%-%-bind=[^%s]+', '')
:gsub('%-%-color=[^%s]+', '')
local function to_fzf_key(key)
if key == '<cr>' then
return 'default'
end
return key:gsub('<c%-(%a)>', function(ch)
return 'ctrl-' .. ch:lower()
end)
end
local function build_actions(picker_name, action_defs)
local cfg = require('forge').config()
local keys = cfg.keys
if keys == false then
keys = {}
end
local bindings = keys[picker_name] or {}
local actions = {}
for _, def in ipairs(action_defs) do
local key = bindings[def.name]
if key then
actions[to_fzf_key(key)] = def.fn
end
end
return actions
end
---@param kind string ---@param kind string
---@param num string ---@param num string
---@param label string ---@param label string
@ -79,13 +51,10 @@ end
---@param f forge.Forge ---@param f forge.Forge
---@param num string ---@param num string
---@return table<string, function> ---@return table<string, function>
local function pr_actions(f, num) local function pr_action_fns(f, num)
local kind = f.labels.pr_one local kind = f.labels.pr_one
return {
local defs = { checkout = function()
{
name = 'checkout',
fn = function()
local forge_mod = require('forge') local forge_mod = require('forge')
forge_mod.log_now(('checking out %s #%s...'):format(kind, num)) forge_mod.log_now(('checking out %s #%s...'):format(kind, num))
vim.system(f:checkout_cmd(num), { text = true }, function(result) vim.system(f:checkout_cmd(num), { text = true }, function(result)
@ -99,16 +68,10 @@ local function pr_actions(f, num)
end) end)
end) end)
end, end,
}, browse = function()
{
name = 'browse',
fn = function()
f:view_web(f.kinds.pr, num) f:view_web(f.kinds.pr, num)
end, end,
}, worktree = function()
{
name = 'worktree',
fn = function()
local forge_mod = require('forge') local forge_mod = require('forge')
local fetch_cmd = f:fetch_pr(num) local fetch_cmd = f:fetch_pr(num)
local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$') local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$')
@ -119,29 +82,19 @@ local function pr_actions(f, num)
local wt_path = vim.fs.normalize(root .. '/../' .. branch) local wt_path = vim.fs.normalize(root .. '/../' .. branch)
forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num)) forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num))
vim.system(fetch_cmd, { text = true }, function() vim.system(fetch_cmd, { text = true }, function()
vim.system( vim.system({ 'git', 'worktree', 'add', wt_path, branch }, { text = true }, function(result)
{ 'git', 'worktree', 'add', wt_path, branch },
{ text = true },
function(result)
vim.schedule(function() vim.schedule(function()
if result.code == 0 then if result.code == 0 then
vim.notify(('[forge]: worktree at %s'):format(wt_path)) vim.notify(('[forge]: worktree at %s'):format(wt_path))
else else
vim.notify( vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR)
'[forge]: ' .. cmd_error(result, 'worktree failed'),
vim.log.levels.ERROR
)
end end
vim.cmd.redraw() vim.cmd.redraw()
end) end)
end end)
)
end) end)
end, end,
}, diff = function()
{
name = 'diff',
fn = function()
local forge_mod = require('forge') local forge_mod = require('forge')
local review = require('forge.review') local review = require('forge.review')
local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel'))
@ -171,10 +124,7 @@ local function pr_actions(f, num)
end) end)
end) end)
end, end,
}, ci = function()
{
name = 'ci',
fn = function()
if f.capabilities.per_pr_checks then if f.capabilities.per_pr_checks then
M.checks(f, num) M.checks(f, num)
else else
@ -184,25 +134,10 @@ local function pr_actions(f, num)
M.ci(f) M.ci(f)
end end
end, end,
}, manage = function()
{
name = 'manage',
fn = function()
M.pr_manage(f, num) M.pr_manage(f, num)
end, end,
},
} }
---@type table<string, function>
local name_to_fn = {}
for _, def in ipairs(defs) do
name_to_fn[def.name] = def.fn
end
local actions = build_actions('pr', defs)
---@type table<string, function>
actions._by_name = name_to_fn
return actions
end end
---@param f forge.Forge ---@param f forge.Forge
@ -223,7 +158,10 @@ local function pr_manage_picker(f, num)
local action_map = {} local action_map = {}
local function add(label, fn) local function add(label, fn)
table.insert(entries, label) table.insert(entries, {
display = { { label } },
value = label,
})
action_map[label] = fn action_map[label] = fn
end end
@ -267,17 +205,20 @@ local function pr_manage_picker(f, num)
end) end)
end end
require('fzf-lua').fzf_exec(entries, { picker.pick({
fzf_args = fzf_args,
prompt = ('%s #%s Actions> '):format(kind, num), prompt = ('%s #%s Actions> '):format(kind, num),
fzf_opts = { ['--no-multi'] = '' }, entries = entries,
actions = { actions = {
['default'] = function(selected) {
if selected[1] and action_map[selected[1]] then name = 'default',
action_map[selected[1]]() fn = function(entry)
if entry and action_map[entry.value] then
action_map[entry.value]()
end end
end, end,
}, },
},
picker_name = '_menu',
}) })
end end
@ -291,18 +232,13 @@ function M.checks(f, num, filter, cached_checks)
local function open_picker(checks) local function open_picker(checks)
local filtered = forge_mod.filter_checks(checks, filter) local filtered = forge_mod.filter_checks(checks, filter)
local lines = {} local entries = {}
for i, c in ipairs(filtered) do for _, c in ipairs(filtered) do
local line = ('%d\t%s'):format(i, forge_mod.format_check(c)) table.insert(entries, {
table.insert(lines, line) display = forge_mod.format_check(c),
end value = c,
ordinal = c.name or '',
local function get_check(selected) })
if not selected[1] then
return nil
end
local idx = tonumber(selected[1]:match('^(%d+)'))
return idx and filtered[idx] or nil
end end
local labels = { local labels = {
@ -312,14 +248,17 @@ function M.checks(f, num, filter, cached_checks)
pending = 'running', pending = 'running',
} }
local check_actions = build_actions('ci', { picker.pick({
prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter),
entries = entries,
actions = {
{ {
name = 'log', name = 'log',
fn = function(selected) fn = function(entry)
local c = get_check(selected) if not entry then
if not c then
return return
end end
local c = entry.value
local run_id = (c.link or ''):match('/actions/runs/(%d+)') local run_id = (c.link or ''):match('/actions/runs/(%d+)')
if not run_id then if not run_id then
return return
@ -346,10 +285,9 @@ function M.checks(f, num, filter, cached_checks)
}, },
{ {
name = 'browse', name = 'browse',
fn = function(selected) fn = function(entry)
local c = get_check(selected) if entry and entry.value.link then
if c and c.link then vim.ui.open(entry.value.link)
vim.ui.open(c.link)
end end
end, end,
}, },
@ -377,18 +315,8 @@ function M.checks(f, num, filter, cached_checks)
M.checks(f, num, 'all', checks) M.checks(f, num, 'all', checks)
end, end,
}, },
})
require('fzf-lua').fzf_exec(lines, {
fzf_args = fzf_args,
prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter),
fzf_opts = {
['--ansi'] = '',
['--no-multi'] = '',
['--with-nth'] = '2..',
['--delimiter'] = '\t',
}, },
actions = check_actions, picker_name = 'ci',
}) })
end end
@ -412,16 +340,7 @@ function M.checks(f, num, filter, cached_checks)
end) end)
end) end)
else else
require('fzf-lua').fzf_exec(f:checks_cmd(num), { vim.notify('[forge]: structured checks not available for this forge', vim.log.levels.INFO)
fzf_args = fzf_args,
prompt = ('Checks (#%s)> '):format(num),
fzf_opts = { ['--ansi'] = '' },
actions = {
['ctrl-r'] = function()
M.checks(f, num, filter)
end,
},
})
end end
end end
@ -430,33 +349,32 @@ end
function M.ci(f, branch) function M.ci(f, branch)
local forge_mod = require('forge') local forge_mod = require('forge')
local function open_picker(runs) local function open_ci_picker(runs)
local normalized = {} local normalized = {}
for _, entry in ipairs(runs) do for _, entry in ipairs(runs) do
table.insert(normalized, f:normalize_run(entry)) table.insert(normalized, f:normalize_run(entry))
end end
local lines = {} local entries = {}
for i, run in ipairs(normalized) do for _, run in ipairs(normalized) do
table.insert(lines, ('%d\t%s'):format(i, forge_mod.format_run(run))) table.insert(entries, {
display = forge_mod.format_run(run),
value = run,
ordinal = run.name .. ' ' .. run.branch,
})
end end
local function get_run(selected) picker.pick({
if not selected[1] then prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'),
return nil entries = entries,
end actions = {
local idx = tonumber(selected[1]:match('^(%d+)'))
return idx and normalized[idx] or nil
end
local ci_actions = build_actions('ci', {
{ {
name = 'log', name = 'log',
fn = function(selected) fn = function(entry)
local run = get_run(selected) if not entry then
if not run then
return return
end end
local run = entry.value
forge_mod.log_now('fetching CI/CD logs...') forge_mod.log_now('fetching CI/CD logs...')
local s = run.status:lower() local s = run.status:lower()
local cmd local cmd
@ -481,10 +399,9 @@ function M.ci(f, branch)
}, },
{ {
name = 'browse', name = 'browse',
fn = function(selected) fn = function(entry)
local run = get_run(selected) if entry and entry.value.url ~= '' then
if run and run.url ~= '' then vim.ui.open(entry.value.url)
vim.ui.open(run.url)
end end
end, end,
}, },
@ -494,18 +411,8 @@ function M.ci(f, branch)
M.ci(f, branch) M.ci(f, branch)
end, end,
}, },
})
require('fzf-lua').fzf_exec(lines, {
fzf_args = fzf_args,
prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'),
fzf_opts = {
['--ansi'] = '',
['--no-multi'] = '',
['--with-nth'] = '2..',
['--delimiter'] = '\t',
}, },
actions = ci_actions, picker_name = 'ci',
}) })
end end
@ -515,7 +422,7 @@ function M.ci(f, branch)
vim.schedule(function() vim.schedule(function()
local ok, runs = pcall(vim.json.decode, result.stdout or '[]') local ok, runs = pcall(vim.json.decode, result.stdout or '[]')
if ok and runs and #runs > 0 then if ok and runs and #runs > 0 then
open_picker(runs) open_ci_picker(runs)
else else
vim.notify('[forge]: no CI runs found', vim.log.levels.INFO) vim.notify('[forge]: no CI runs found', vim.log.levels.INFO)
vim.cmd.redraw() vim.cmd.redraw()
@ -523,11 +430,7 @@ function M.ci(f, branch)
end) end)
end) end)
elseif f.list_runs_cmd then elseif f.list_runs_cmd then
require('fzf-lua').fzf_exec(f:list_runs_cmd(branch), { vim.notify('[forge]: structured CI data not available for this forge', vim.log.levels.INFO)
fzf_args = fzf_args,
prompt = f.labels.ci .. '> ',
fzf_opts = { ['--ansi'] = '' },
})
end end
end end
@ -535,6 +438,27 @@ end
function M.commits(f) function M.commits(f)
local forge_mod = require('forge') local forge_mod = require('forge')
local review = require('forge.review') local review = require('forge.review')
if picker.backend() ~= 'fzf-lua' then
vim.notify('[forge]: commits picker requires fzf-lua', vim.log.levels.WARN)
return
end
local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '')
:gsub('%-%-bind=[^%s]+', '')
:gsub('%-%-color=[^%s]+', '')
local function to_fzf_key(key)
if key == '<cr>' then
return 'default'
end
return key:gsub('<c%-(%a)>', function(ch)
return 'ctrl-' .. ch:lower()
end)
end
local cfg = require('forge').config()
local keys = type(cfg.keys) == 'table' and cfg.keys.commits or {}
local log_cmd = local log_cmd =
'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"' 'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"'
@ -545,10 +469,9 @@ function M.commits(f)
end end
end end
local defs = { local fzf_actions = {}
{ if keys.checkout then
name = 'checkout', fzf_actions[to_fzf_key(keys.checkout)] = function(selected)
fn = function(selected)
with_sha(selected, function(sha) with_sha(selected, function(sha)
forge_mod.log_now('checking out ' .. sha .. '...') forge_mod.log_now('checking out ' .. sha .. '...')
vim.system({ 'git', 'checkout', sha }, { text = true }, function(result) vim.system({ 'git', 'checkout', sha }, { text = true }, function(result)
@ -556,20 +479,16 @@ function M.commits(f)
if result.code == 0 then if result.code == 0 then
vim.notify(('[forge]: checked out %s (detached)'):format(sha)) vim.notify(('[forge]: checked out %s (detached)'):format(sha))
else else
vim.notify( vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR)
'[forge]: ' .. cmd_error(result, 'checkout failed'),
vim.log.levels.ERROR
)
end end
vim.cmd.redraw() vim.cmd.redraw()
end) end)
end) end)
end) end)
end, end
}, end
{ if keys.diff then
name = 'diff', fzf_actions[to_fzf_key(keys.diff)] = function(selected)
fn = function(selected)
with_sha(selected, function(sha) with_sha(selected, function(sha)
local range = sha .. '^..' .. sha local range = sha .. '^..' .. sha
review.start(range) review.start(range)
@ -579,30 +498,25 @@ function M.commits(f)
end end
forge_mod.log_now('reviewing ' .. sha) forge_mod.log_now('reviewing ' .. sha)
end) end)
end, end
}, end
{ if keys.browse then
name = 'browse', fzf_actions[to_fzf_key(keys.browse)] = function(selected)
fn = function(selected)
with_sha(selected, function(sha) with_sha(selected, function(sha)
if f then if f then
f:browse_commit(sha) f:browse_commit(sha)
end end
end) end)
end, end
}, end
{ if keys.yank then
name = 'yank', fzf_actions[to_fzf_key(keys.yank)] = function(selected)
fn = function(selected)
with_sha(selected, function(sha) with_sha(selected, function(sha)
vim.fn.setreg('+', sha) vim.fn.setreg('+', sha)
vim.notify('[forge]: copied ' .. sha) vim.notify('[forge]: copied ' .. sha)
end) end)
end, end
}, end
}
local commit_actions = build_actions('commits', defs)
require('fzf-lua').fzf_exec(log_cmd, { require('fzf-lua').fzf_exec(log_cmd, {
fzf_args = fzf_args, fzf_args = fzf_args,
@ -612,7 +526,7 @@ function M.commits(f)
['--no-multi'] = '', ['--no-multi'] = '',
['--preview'] = 'git show --color {1}', ['--preview'] = 'git show --color {1}',
}, },
actions = commit_actions, actions = fzf_actions,
}) })
end end
@ -621,10 +535,26 @@ function M.branches(f)
local forge_mod = require('forge') local forge_mod = require('forge')
local review = require('forge.review') local review = require('forge.review')
local defs = { if picker.backend() ~= 'fzf-lua' then
{ vim.notify('[forge]: branches picker requires fzf-lua', vim.log.levels.WARN)
name = 'diff', return
fn = function(selected) end
local function to_fzf_key(key)
if key == '<cr>' then
return 'default'
end
return key:gsub('<c%-(%a)>', function(ch)
return 'ctrl-' .. ch:lower()
end)
end
local cfg = require('forge').config()
local keys = type(cfg.keys) == 'table' and cfg.keys.branches or {}
local fzf_actions = {}
if keys.diff then
fzf_actions[to_fzf_key(keys.diff)] = function(selected)
if not selected[1] then if not selected[1] then
return return
end end
@ -638,11 +568,10 @@ function M.branches(f)
commands.greview(br) commands.greview(br)
end end
forge_mod.log_now('reviewing ' .. br) forge_mod.log_now('reviewing ' .. br)
end, end
}, end
{ if keys.browse then
name = 'browse', fzf_actions[to_fzf_key(keys.browse)] = function(selected)
fn = function(selected)
if not selected[1] then if not selected[1] then
return return
end end
@ -650,12 +579,10 @@ function M.branches(f)
if br and f then if br and f then
f:browse_branch(br) f:browse_branch(br)
end end
end, end
}, end
}
local branch_actions = build_actions('branches', defs) require('fzf-lua').git_branches({ actions = fzf_actions })
require('fzf-lua').git_branches({ actions = branch_actions })
end end
---@param state 'all'|'open'|'closed' ---@param state 'all'|'open'|'closed'
@ -669,64 +596,66 @@ function M.pr(state, f)
local show_state = state ~= 'open' local show_state = state ~= 'open'
local function open_pr_list(prs) local function open_pr_list(prs)
local lines = {} local entries = {}
for _, pr in ipairs(prs) do for _, pr in ipairs(prs) do
table.insert(lines, forge_mod.format_pr(pr, pr_fields, show_state)) local num = tostring(pr[pr_fields.number] or '')
end table.insert(entries, {
local function with_pr_num(selected, fn) display = forge_mod.format_pr(pr, pr_fields, show_state),
local num = selected[1] and selected[1]:match('[#!](%d+)') value = num,
if num then ordinal = (pr[pr_fields.title] or '') .. ' #' .. num,
fn(num) })
end
end end
local list_actions = build_actions('pr', { picker.pick({
prompt = ('%s (%s)> '):format(f.labels.pr, state),
entries = entries,
actions = {
{ {
name = 'checkout', name = 'checkout',
fn = function(selected) fn = function(entry)
with_pr_num(selected, function(num) if entry then
pr_actions(f, num)._by_name['checkout']() pr_action_fns(f, entry.value).checkout()
end) end
end, end,
}, },
{ {
name = 'diff', name = 'diff',
fn = function(selected) fn = function(entry)
with_pr_num(selected, function(num) if entry then
pr_actions(f, num)._by_name['diff']() pr_action_fns(f, entry.value).diff()
end) end
end, end,
}, },
{ {
name = 'worktree', name = 'worktree',
fn = function(selected) fn = function(entry)
with_pr_num(selected, function(num) if entry then
pr_actions(f, num)._by_name['worktree']() pr_action_fns(f, entry.value).worktree()
end) end
end, end,
}, },
{ {
name = 'ci', name = 'ci',
fn = function(selected) fn = function(entry)
with_pr_num(selected, function(num) if entry then
pr_actions(f, num)._by_name['ci']() pr_action_fns(f, entry.value).ci()
end) end
end, end,
}, },
{ {
name = 'browse', name = 'browse',
fn = function(selected) fn = function(entry)
with_pr_num(selected, function(num) if entry then
f:view_web(cli_kind, num) f:view_web(cli_kind, entry.value)
end) end
end, end,
}, },
{ {
name = 'manage', name = 'manage',
fn = function(selected) fn = function(entry)
with_pr_num(selected, function(num) if entry then
pr_actions(f, num)._by_name['manage']() pr_action_fns(f, entry.value).manage()
end) end
end, end,
}, },
{ {
@ -748,16 +677,8 @@ function M.pr(state, f)
M.pr(state, f) M.pr(state, f)
end, end,
}, },
})
require('fzf-lua').fzf_exec(lines, {
fzf_args = fzf_args,
prompt = ('%s (%s)> '):format(f.labels.pr, state),
fzf_opts = {
['--ansi'] = '',
['--no-multi'] = '',
}, },
actions = list_actions, picker_name = 'pr',
}) })
end end
@ -795,35 +716,36 @@ function M.issue(state, f)
end) end)
local state_field = issue_fields.state local state_field = issue_fields.state
local state_map = {} local state_map = {}
local lines = {} local entries = {}
for _, issue in ipairs(issues) do for _, issue in ipairs(issues) do
local n = tostring(issue[num_field] or '') local n = tostring(issue[num_field] or '')
local s = (issue[state_field] or ''):lower() local s = (issue[state_field] or ''):lower()
state_map[n] = s == 'open' or s == 'opened' state_map[n] = s == 'open' or s == 'opened'
table.insert(lines, forge_mod.format_issue(issue, issue_fields, issue_show_state)) table.insert(entries, {
end display = forge_mod.format_issue(issue, issue_fields, issue_show_state),
local function with_issue_num(selected, fn) value = n,
local num = selected[1] and selected[1]:match('[#!](%d+)') ordinal = (issue[issue_fields.title] or '') .. ' #' .. n,
if num then })
fn(num)
end
end end
local issue_actions = build_actions('issue', { picker.pick({
prompt = ('%s (%s)> '):format(f.labels.issue, state),
entries = entries,
actions = {
{ {
name = 'browse', name = 'browse',
fn = function(selected) fn = function(entry)
with_issue_num(selected, function(num) if entry then
f:view_web(cli_kind, num) f:view_web(cli_kind, entry.value)
end) end
end, end,
}, },
{ {
name = 'close', name = 'close',
fn = function(selected) fn = function(entry)
with_issue_num(selected, function(num) if entry then
issue_toggle_state(f, num, state_map[num] ~= false) issue_toggle_state(f, entry.value, state_map[entry.value] ~= false)
end) end
end, end,
}, },
{ {
@ -839,16 +761,8 @@ function M.issue(state, f)
M.issue(state, f) M.issue(state, f)
end, end,
}, },
})
require('fzf-lua').fzf_exec(lines, {
fzf_args = fzf_args,
prompt = ('%s (%s)> '):format(f.labels.issue, state),
fzf_opts = {
['--ansi'] = '',
['--no-multi'] = '',
}, },
actions = issue_actions, picker_name = 'issue',
}) })
end end
@ -891,7 +805,7 @@ end
---@param num string ---@param num string
---@return table<string, function> ---@return table<string, function>
function M.pr_actions(f, num) function M.pr_actions(f, num)
return pr_actions(f, num) return pr_action_fns(f, num)
end end
function M.git() function M.git()
@ -913,11 +827,14 @@ function M.git()
local branch = vim.trim(vim.fn.system('git branch --show-current')) local branch = vim.trim(vim.fn.system('git branch --show-current'))
local items = {} local items = {}
local actions = {} local action_map = {}
local function add(label, action) local function add(label, action)
table.insert(items, label) table.insert(items, {
actions[label] = action display = { { label } },
value = label,
})
action_map[label] = action
end end
if f then if f then
@ -968,21 +885,29 @@ function M.git()
end) end)
add('Worktrees', function() add('Worktrees', function()
if picker.backend() == 'fzf-lua' then
require('fzf-lua').git_worktrees() require('fzf-lua').git_worktrees()
else
vim.notify('[forge]: worktrees picker requires fzf-lua', vim.log.levels.WARN)
end
end) end)
local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> ' local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> '
require('fzf-lua').fzf_exec(items, { picker.pick({
fzf_args = fzf_args,
prompt = prompt, prompt = prompt,
entries = items,
actions = { actions = {
['default'] = function(selected) {
if selected[1] and actions[selected[1]] then name = 'default',
actions[selected[1]]() fn = function(entry)
if entry and action_map[entry.value] then
action_map[entry.value]()
end end
end, end,
}, },
},
picker_name = '_menu',
}) })
end end