mirror of
https://github.com/harivansh-afk/forge.nvim.git
synced 2026-04-15 03:00:46 +00:00
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:
parent
354c5000c0
commit
fa7cab89af
6 changed files with 826 additions and 599 deletions
|
|
@ -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)
|
||||
end
|
||||
elapsed = format_duration(te - ts)
|
||||
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
76
lua/forge/picker/fzf.lua
Normal 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
80
lua/forge/picker/init.lua
Normal 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
|
||||
64
lua/forge/picker/snacks.lua
Normal file
64
lua/forge/picker/snacks.lua
Normal 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
|
||||
69
lua/forge/picker/telescope.lua
Normal file
69
lua/forge/picker/telescope.lua
Normal 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
Loading…
Add table
Add a link
Reference in a new issue