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 = {}
---@class forge.Config
---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto'
---@field ci forge.CIConfig
---@field sources table<string, forge.SourceConfig>
---@field keys forge.KeysConfig|false
@ -83,6 +84,7 @@ local M = {}
---@type forge.Config
local DEFAULTS = {
picker = 'auto',
ci = { lines = 10000 },
sources = {},
keys = {
@ -538,15 +540,25 @@ local function extract_author(entry, field)
return tostring(v or '')
end
local function hl(group, text)
local utils = require('fzf-lua.utils')
return utils.ansi_from_hl(group, text)
---@param secs integer
---@return string
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
---@param entry table
---@param fields table
---@param show_state boolean
---@return string
---@return forge.Segment[]
function M.format_pr(entry, fields, show_state)
local display = M.config().display
local icons = display.icons
@ -555,7 +567,7 @@ function M.format_pr(entry, fields, show_state)
local title = entry[fields.title] or ''
local author = extract_author(entry, fields.author)
local age = relative_time(entry[fields.created_at])
local prefix = ''
local segments = {}
if show_state then
local state = (entry[fields.state] or ''):lower()
local icon, group
@ -566,20 +578,22 @@ function M.format_pr(entry, fields, show_state)
else
icon, group = icons.closed, 'ForgeClosed'
end
prefix = hl(group, icon) .. ' '
table.insert(segments, { icon, group })
table.insert(segments, { ' ' })
end
return prefix
.. hl('ForgeNumber', ('#%-5s'):format(num))
.. ' '
.. pad_or_truncate(title, widths.title)
.. ' '
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
table.insert(segments, {
pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
'ForgeDim',
})
return segments
end
---@param entry table
---@param fields table
---@param show_state boolean
---@return string
---@return forge.Segment[]
function M.format_issue(entry, fields, show_state)
local display = M.config().display
local icons = display.icons
@ -588,7 +602,7 @@ function M.format_issue(entry, fields, show_state)
local title = entry[fields.title] or ''
local author = extract_author(entry, fields.author)
local age = relative_time(entry[fields.created_at])
local prefix = ''
local segments = {}
if show_state then
local state = (entry[fields.state] or ''):lower()
local icon, group
@ -597,18 +611,20 @@ function M.format_issue(entry, fields, show_state)
else
icon, group = icons.closed, 'ForgeClosed'
end
prefix = hl(group, icon) .. ' '
table.insert(segments, { icon, group })
table.insert(segments, { ' ' })
end
return prefix
.. hl('ForgeNumber', ('#%-5s'):format(num))
.. ' '
.. pad_or_truncate(title, widths.title)
.. ' '
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
table.insert(segments, {
pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
'ForgeDim',
})
return segments
end
---@param check table
---@return string
---@return forge.Segment[]
function M.format_check(check)
local display = M.config().display
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_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
local secs = te - ts
if secs >= 60 then
elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
else
elapsed = ('%ds'):format(secs)
elapsed = format_duration(te - ts)
end
end
end
return hl(group, icon)
.. ' '
.. pad_or_truncate(name, widths.name)
.. ' '
.. hl('ForgeDim', elapsed)
return {
{ icon, group },
{ ' ' .. pad_or_truncate(name, widths.name) .. ' ' },
{ elapsed, 'ForgeDim' },
}
end
---@param run forge.CIRun
---@return string
---@return forge.Segment[]
function M.format_run(run)
local display = M.config().display
local icons = display.icons
@ -670,19 +681,18 @@ function M.format_run(run)
local age = relative_time(run.created_at)
if run.branch ~= '' then
local name_w = widths.name - widths.branch + 10
return hl(group, icon)
.. ' '
.. pad_or_truncate(run.name, name_w)
.. ' '
.. hl('ForgeBranch', pad_or_truncate(run.branch, widths.branch))
.. ' '
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
return {
{ icon, group },
{ ' ' .. pad_or_truncate(run.name, name_w) .. ' ' },
{ pad_or_truncate(run.branch, widths.branch), 'ForgeBranch' },
{ ' ' .. ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
}
end
return hl(group, icon)
.. ' '
.. pad_or_truncate(run.name, widths.name)
.. ' '
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
return {
{ icon, group },
{ ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' },
{ ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
}
end
---@param checks table[]
@ -715,6 +725,9 @@ function M.config()
cfg.keys = false
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.keys', cfg.keys, function(v)
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 picker = require('forge.picker')
---@param result { code: integer, stdout: string?, stderr: string? }
---@param fallback string
---@return string
@ -15,36 +17,6 @@ local function cmd_error(result, fallback)
return msg
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 num string
---@param label string
@ -79,13 +51,10 @@ end
---@param f forge.Forge
---@param num string
---@return table<string, function>
local function pr_actions(f, num)
local function pr_action_fns(f, num)
local kind = f.labels.pr_one
local defs = {
{
name = 'checkout',
fn = function()
return {
checkout = function()
local forge_mod = require('forge')
forge_mod.log_now(('checking out %s #%s...'):format(kind, num))
vim.system(f:checkout_cmd(num), { text = true }, function(result)
@ -99,16 +68,10 @@ local function pr_actions(f, num)
end)
end)
end,
},
{
name = 'browse',
fn = function()
browse = function()
f:view_web(f.kinds.pr, num)
end,
},
{
name = 'worktree',
fn = function()
worktree = function()
local forge_mod = require('forge')
local fetch_cmd = f:fetch_pr(num)
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)
forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num))
vim.system(fetch_cmd, { text = true }, function()
vim.system(
{ 'git', 'worktree', 'add', wt_path, branch },
{ text = true },
function(result)
vim.system({ 'git', 'worktree', 'add', wt_path, branch }, { text = true }, function(result)
vim.schedule(function()
if result.code == 0 then
vim.notify(('[forge]: worktree at %s'):format(wt_path))
else
vim.notify(
'[forge]: ' .. cmd_error(result, 'worktree failed'),
vim.log.levels.ERROR
)
vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR)
end
vim.cmd.redraw()
end)
end
)
end)
end)
end,
},
{
name = 'diff',
fn = function()
diff = function()
local forge_mod = require('forge')
local review = require('forge.review')
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,
},
{
name = 'ci',
fn = function()
ci = function()
if f.capabilities.per_pr_checks then
M.checks(f, num)
else
@ -184,25 +134,10 @@ local function pr_actions(f, num)
M.ci(f)
end
end,
},
{
name = 'manage',
fn = function()
manage = function()
M.pr_manage(f, num)
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
---@param f forge.Forge
@ -223,7 +158,10 @@ local function pr_manage_picker(f, num)
local action_map = {}
local function add(label, fn)
table.insert(entries, label)
table.insert(entries, {
display = { { label } },
value = label,
})
action_map[label] = fn
end
@ -267,17 +205,20 @@ local function pr_manage_picker(f, num)
end)
end
require('fzf-lua').fzf_exec(entries, {
fzf_args = fzf_args,
picker.pick({
prompt = ('%s #%s Actions> '):format(kind, num),
fzf_opts = { ['--no-multi'] = '' },
entries = entries,
actions = {
['default'] = function(selected)
if selected[1] and action_map[selected[1]] then
action_map[selected[1]]()
{
name = 'default',
fn = function(entry)
if entry and action_map[entry.value] then
action_map[entry.value]()
end
end,
},
},
picker_name = '_menu',
})
end
@ -291,18 +232,13 @@ function M.checks(f, num, filter, cached_checks)
local function open_picker(checks)
local filtered = forge_mod.filter_checks(checks, filter)
local lines = {}
for i, c in ipairs(filtered) do
local line = ('%d\t%s'):format(i, forge_mod.format_check(c))
table.insert(lines, line)
end
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
local entries = {}
for _, c in ipairs(filtered) do
table.insert(entries, {
display = forge_mod.format_check(c),
value = c,
ordinal = c.name or '',
})
end
local labels = {
@ -312,14 +248,17 @@ function M.checks(f, num, filter, cached_checks)
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',
fn = function(selected)
local c = get_check(selected)
if not c then
fn = function(entry)
if not entry then
return
end
local c = entry.value
local run_id = (c.link or ''):match('/actions/runs/(%d+)')
if not run_id then
return
@ -346,10 +285,9 @@ function M.checks(f, num, filter, cached_checks)
},
{
name = 'browse',
fn = function(selected)
local c = get_check(selected)
if c and c.link then
vim.ui.open(c.link)
fn = function(entry)
if entry and entry.value.link then
vim.ui.open(entry.value.link)
end
end,
},
@ -377,18 +315,8 @@ function M.checks(f, num, filter, cached_checks)
M.checks(f, num, 'all', checks)
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
@ -412,16 +340,7 @@ function M.checks(f, num, filter, cached_checks)
end)
end)
else
require('fzf-lua').fzf_exec(f:checks_cmd(num), {
fzf_args = fzf_args,
prompt = ('Checks (#%s)> '):format(num),
fzf_opts = { ['--ansi'] = '' },
actions = {
['ctrl-r'] = function()
M.checks(f, num, filter)
end,
},
})
vim.notify('[forge]: structured checks not available for this forge', vim.log.levels.INFO)
end
end
@ -430,33 +349,32 @@ end
function M.ci(f, branch)
local forge_mod = require('forge')
local function open_picker(runs)
local function open_ci_picker(runs)
local normalized = {}
for _, entry in ipairs(runs) do
table.insert(normalized, f:normalize_run(entry))
end
local lines = {}
for i, run in ipairs(normalized) do
table.insert(lines, ('%d\t%s'):format(i, forge_mod.format_run(run)))
local entries = {}
for _, run in ipairs(normalized) do
table.insert(entries, {
display = forge_mod.format_run(run),
value = run,
ordinal = run.name .. ' ' .. run.branch,
})
end
local function get_run(selected)
if not selected[1] then
return nil
end
local idx = tonumber(selected[1]:match('^(%d+)'))
return idx and normalized[idx] or nil
end
local ci_actions = build_actions('ci', {
picker.pick({
prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'),
entries = entries,
actions = {
{
name = 'log',
fn = function(selected)
local run = get_run(selected)
if not run then
fn = function(entry)
if not entry then
return
end
local run = entry.value
forge_mod.log_now('fetching CI/CD logs...')
local s = run.status:lower()
local cmd
@ -481,10 +399,9 @@ function M.ci(f, branch)
},
{
name = 'browse',
fn = function(selected)
local run = get_run(selected)
if run and run.url ~= '' then
vim.ui.open(run.url)
fn = function(entry)
if entry and entry.value.url ~= '' then
vim.ui.open(entry.value.url)
end
end,
},
@ -494,18 +411,8 @@ function M.ci(f, branch)
M.ci(f, branch)
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
@ -515,7 +422,7 @@ function M.ci(f, branch)
vim.schedule(function()
local ok, runs = pcall(vim.json.decode, result.stdout or '[]')
if ok and runs and #runs > 0 then
open_picker(runs)
open_ci_picker(runs)
else
vim.notify('[forge]: no CI runs found', vim.log.levels.INFO)
vim.cmd.redraw()
@ -523,11 +430,7 @@ function M.ci(f, branch)
end)
end)
elseif f.list_runs_cmd then
require('fzf-lua').fzf_exec(f:list_runs_cmd(branch), {
fzf_args = fzf_args,
prompt = f.labels.ci .. '> ',
fzf_opts = { ['--ansi'] = '' },
})
vim.notify('[forge]: structured CI data not available for this forge', vim.log.levels.INFO)
end
end
@ -535,6 +438,27 @@ end
function M.commits(f)
local forge_mod = require('forge')
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 =
'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
local defs = {
{
name = 'checkout',
fn = function(selected)
local fzf_actions = {}
if keys.checkout then
fzf_actions[to_fzf_key(keys.checkout)] = function(selected)
with_sha(selected, function(sha)
forge_mod.log_now('checking out ' .. sha .. '...')
vim.system({ 'git', 'checkout', sha }, { text = true }, function(result)
@ -556,20 +479,16 @@ function M.commits(f)
if result.code == 0 then
vim.notify(('[forge]: checked out %s (detached)'):format(sha))
else
vim.notify(
'[forge]: ' .. cmd_error(result, 'checkout failed'),
vim.log.levels.ERROR
)
vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR)
end
vim.cmd.redraw()
end)
end)
end)
end,
},
{
name = 'diff',
fn = function(selected)
end
end
if keys.diff then
fzf_actions[to_fzf_key(keys.diff)] = function(selected)
with_sha(selected, function(sha)
local range = sha .. '^..' .. sha
review.start(range)
@ -579,30 +498,25 @@ function M.commits(f)
end
forge_mod.log_now('reviewing ' .. sha)
end)
end,
},
{
name = 'browse',
fn = function(selected)
end
end
if keys.browse then
fzf_actions[to_fzf_key(keys.browse)] = function(selected)
with_sha(selected, function(sha)
if f then
f:browse_commit(sha)
end
end)
end,
},
{
name = 'yank',
fn = function(selected)
end
end
if keys.yank then
fzf_actions[to_fzf_key(keys.yank)] = function(selected)
with_sha(selected, function(sha)
vim.fn.setreg('+', sha)
vim.notify('[forge]: copied ' .. sha)
end)
end,
},
}
local commit_actions = build_actions('commits', defs)
end
end
require('fzf-lua').fzf_exec(log_cmd, {
fzf_args = fzf_args,
@ -612,7 +526,7 @@ function M.commits(f)
['--no-multi'] = '',
['--preview'] = 'git show --color {1}',
},
actions = commit_actions,
actions = fzf_actions,
})
end
@ -621,10 +535,26 @@ function M.branches(f)
local forge_mod = require('forge')
local review = require('forge.review')
local defs = {
{
name = 'diff',
fn = function(selected)
if picker.backend() ~= 'fzf-lua' then
vim.notify('[forge]: branches picker requires fzf-lua', vim.log.levels.WARN)
return
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
return
end
@ -638,11 +568,10 @@ function M.branches(f)
commands.greview(br)
end
forge_mod.log_now('reviewing ' .. br)
end,
},
{
name = 'browse',
fn = function(selected)
end
end
if keys.browse then
fzf_actions[to_fzf_key(keys.browse)] = function(selected)
if not selected[1] then
return
end
@ -650,12 +579,10 @@ function M.branches(f)
if br and f then
f:browse_branch(br)
end
end,
},
}
end
end
local branch_actions = build_actions('branches', defs)
require('fzf-lua').git_branches({ actions = branch_actions })
require('fzf-lua').git_branches({ actions = fzf_actions })
end
---@param state 'all'|'open'|'closed'
@ -669,64 +596,66 @@ function M.pr(state, f)
local show_state = state ~= 'open'
local function open_pr_list(prs)
local lines = {}
local entries = {}
for _, pr in ipairs(prs) do
table.insert(lines, forge_mod.format_pr(pr, pr_fields, show_state))
end
local function with_pr_num(selected, fn)
local num = selected[1] and selected[1]:match('[#!](%d+)')
if num then
fn(num)
end
local num = tostring(pr[pr_fields.number] or '')
table.insert(entries, {
display = forge_mod.format_pr(pr, pr_fields, show_state),
value = num,
ordinal = (pr[pr_fields.title] or '') .. ' #' .. num,
})
end
local list_actions = build_actions('pr', {
picker.pick({
prompt = ('%s (%s)> '):format(f.labels.pr, state),
entries = entries,
actions = {
{
name = 'checkout',
fn = function(selected)
with_pr_num(selected, function(num)
pr_actions(f, num)._by_name['checkout']()
end)
fn = function(entry)
if entry then
pr_action_fns(f, entry.value).checkout()
end
end,
},
{
name = 'diff',
fn = function(selected)
with_pr_num(selected, function(num)
pr_actions(f, num)._by_name['diff']()
end)
fn = function(entry)
if entry then
pr_action_fns(f, entry.value).diff()
end
end,
},
{
name = 'worktree',
fn = function(selected)
with_pr_num(selected, function(num)
pr_actions(f, num)._by_name['worktree']()
end)
fn = function(entry)
if entry then
pr_action_fns(f, entry.value).worktree()
end
end,
},
{
name = 'ci',
fn = function(selected)
with_pr_num(selected, function(num)
pr_actions(f, num)._by_name['ci']()
end)
fn = function(entry)
if entry then
pr_action_fns(f, entry.value).ci()
end
end,
},
{
name = 'browse',
fn = function(selected)
with_pr_num(selected, function(num)
f:view_web(cli_kind, num)
end)
fn = function(entry)
if entry then
f:view_web(cli_kind, entry.value)
end
end,
},
{
name = 'manage',
fn = function(selected)
with_pr_num(selected, function(num)
pr_actions(f, num)._by_name['manage']()
end)
fn = function(entry)
if entry then
pr_action_fns(f, entry.value).manage()
end
end,
},
{
@ -748,16 +677,8 @@ function M.pr(state, f)
M.pr(state, f)
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
@ -795,35 +716,36 @@ function M.issue(state, f)
end)
local state_field = issue_fields.state
local state_map = {}
local lines = {}
local entries = {}
for _, issue in ipairs(issues) do
local n = tostring(issue[num_field] or '')
local s = (issue[state_field] or ''):lower()
state_map[n] = s == 'open' or s == 'opened'
table.insert(lines, forge_mod.format_issue(issue, issue_fields, issue_show_state))
end
local function with_issue_num(selected, fn)
local num = selected[1] and selected[1]:match('[#!](%d+)')
if num then
fn(num)
end
table.insert(entries, {
display = forge_mod.format_issue(issue, issue_fields, issue_show_state),
value = n,
ordinal = (issue[issue_fields.title] or '') .. ' #' .. n,
})
end
local issue_actions = build_actions('issue', {
picker.pick({
prompt = ('%s (%s)> '):format(f.labels.issue, state),
entries = entries,
actions = {
{
name = 'browse',
fn = function(selected)
with_issue_num(selected, function(num)
f:view_web(cli_kind, num)
end)
fn = function(entry)
if entry then
f:view_web(cli_kind, entry.value)
end
end,
},
{
name = 'close',
fn = function(selected)
with_issue_num(selected, function(num)
issue_toggle_state(f, num, state_map[num] ~= false)
end)
fn = function(entry)
if entry then
issue_toggle_state(f, entry.value, state_map[entry.value] ~= false)
end
end,
},
{
@ -839,16 +761,8 @@ function M.issue(state, f)
M.issue(state, f)
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
@ -891,7 +805,7 @@ end
---@param num string
---@return table<string, function>
function M.pr_actions(f, num)
return pr_actions(f, num)
return pr_action_fns(f, num)
end
function M.git()
@ -913,11 +827,14 @@ function M.git()
local branch = vim.trim(vim.fn.system('git branch --show-current'))
local items = {}
local actions = {}
local action_map = {}
local function add(label, action)
table.insert(items, label)
actions[label] = action
table.insert(items, {
display = { { label } },
value = label,
})
action_map[label] = action
end
if f then
@ -968,21 +885,29 @@ function M.git()
end)
add('Worktrees', function()
if picker.backend() == 'fzf-lua' then
require('fzf-lua').git_worktrees()
else
vim.notify('[forge]: worktrees picker requires fzf-lua', vim.log.levels.WARN)
end
end)
local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> '
require('fzf-lua').fzf_exec(items, {
fzf_args = fzf_args,
picker.pick({
prompt = prompt,
entries = items,
actions = {
['default'] = function(selected)
if selected[1] and actions[selected[1]] then
actions[selected[1]]()
{
name = 'default',
fn = function(entry)
if entry and action_map[entry.value] then
action_map[entry.value]()
end
end,
},
},
picker_name = '_menu',
})
end