From fa7cab89affed7ab149e891bebcbd86b85f0b97e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 28 Mar 2026 17:48:24 -0400 Subject: [PATCH] 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. --- lua/forge/init.lua | 105 ++-- lua/forge/picker/fzf.lua | 76 +++ lua/forge/picker/init.lua | 80 +++ lua/forge/picker/snacks.lua | 64 ++ lua/forge/picker/telescope.lua | 69 +++ lua/forge/pickers.lua | 1031 +++++++++++++++----------------- 6 files changed, 826 insertions(+), 599 deletions(-) create mode 100644 lua/forge/picker/fzf.lua create mode 100644 lua/forge/picker/init.lua create mode 100644 lua/forge/picker/snacks.lua create mode 100644 lua/forge/picker/telescope.lua diff --git a/lua/forge/init.lua b/lua/forge/init.lua index 894abb4..d8085d1 100644 --- a/lua/forge/init.lua +++ b/lua/forge/init.lua @@ -1,6 +1,7 @@ local M = {} ---@class forge.Config +---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto' ---@field ci forge.CIConfig ---@field sources table ---@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' diff --git a/lua/forge/picker/fzf.lua b/lua/forge/picker/fzf.lua new file mode 100644 index 0000000..d4d3a05 --- /dev/null +++ b/lua/forge/picker/fzf.lua @@ -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 == '' then + return 'default' + end + local result = key:gsub('', 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 '' 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 diff --git a/lua/forge/picker/init.lua b/lua/forge/picker/init.lua new file mode 100644 index 0000000..8956791 --- /dev/null +++ b/lua/forge/picker/init.lua @@ -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 +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 diff --git a/lua/forge/picker/snacks.lua b/lua/forge/picker/snacks.lua new file mode 100644 index 0000000..72c6501 --- /dev/null +++ b/lua/forge/picker/snacks.lua @@ -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 '' 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 == '' 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 diff --git a/lua/forge/picker/telescope.lua b/lua/forge/picker/telescope.lua new file mode 100644 index 0000000..026ae3c --- /dev/null +++ b/lua/forge/picker/telescope.lua @@ -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 '' 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 == '' 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 diff --git a/lua/forge/pickers.lua b/lua/forge/pickers.lua index 3377035..60b2139 100644 --- a/lua/forge/pickers.lua +++ b/lua/forge/pickers.lua @@ -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 == '' then - return 'default' - end - return key:gsub('', 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,130 +51,93 @@ end ---@param f forge.Forge ---@param num string ---@return table -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() - 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) + 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) + vim.schedule(function() + if result.code == 0 then + vim.notify(('[forge]: checked out %s #%s'):format(kind, num)) + else + vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) + end + vim.cmd.redraw() + end) + end) + end, + browse = function() + f:view_web(f.kinds.pr, num) + end, + worktree = function() + local forge_mod = require('forge') + local fetch_cmd = f:fetch_pr(num) + local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$') + if not branch then + return + end + local root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) + 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.schedule(function() if result.code == 0 then - vim.notify(('[forge]: checked out %s #%s'):format(kind, num)) + vim.notify(('[forge]: worktree at %s'):format(wt_path)) else - vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) + vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR) end vim.cmd.redraw() end) end) - end, - }, - { - name = 'browse', - fn = function() - f:view_web(f.kinds.pr, num) - end, - }, - { - name = 'worktree', - fn = function() - local forge_mod = require('forge') - local fetch_cmd = f:fetch_pr(num) - local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$') - if not branch then - return + end) + end, + 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')) + + forge_mod.log_now(('reviewing %s #%s...'):format(kind, num)) + vim.system(f:checkout_cmd(num), { text = true }, function(co_result) + if co_result.code ~= 0 then + vim.schedule(function() + forge_mod.log('checkout skipped, proceeding with diff') + end) end - local root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) - 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.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 - ) - end - vim.cmd.redraw() - end) + + vim.system(f:pr_base_cmd(num), { text = true }, function(base_result) + vim.schedule(function() + local base = vim.trim(base_result.stdout or '') + if base == '' or base_result.code ~= 0 then + base = 'main' end - ) - end) - end, - }, - { - name = 'diff', - fn = function() - local forge_mod = require('forge') - local review = require('forge.review') - local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) - - forge_mod.log_now(('reviewing %s #%s...'):format(kind, num)) - vim.system(f:checkout_cmd(num), { text = true }, function(co_result) - if co_result.code ~= 0 then - vim.schedule(function() - forge_mod.log('checkout skipped, proceeding with diff') - end) - end - - vim.system(f:pr_base_cmd(num), { text = true }, function(base_result) - vim.schedule(function() - local base = vim.trim(base_result.stdout or '') - if base == '' or base_result.code ~= 0 then - base = 'main' - end - local range = 'origin/' .. base - review.start(range) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(range, { repo_root = repo_root }) - end - forge_mod.log(('review ready for %s #%s against %s'):format(kind, num, base)) - end) + local range = 'origin/' .. base + review.start(range) + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(range, { repo_root = repo_root }) + end + forge_mod.log(('review ready for %s #%s against %s'):format(kind, num, base)) end) end) - end, - }, - { - name = 'ci', - fn = function() - if f.capabilities.per_pr_checks then - M.checks(f, num) - else - require('forge').log( - ('per-%s checks unavailable on %s, showing repo CI'):format(kind, f.name) - ) - M.ci(f) - end - end, - }, - { - name = 'manage', - fn = function() - M.pr_manage(f, num) - end, - }, + end) + end, + ci = function() + if f.capabilities.per_pr_checks then + M.checks(f, num) + else + require('forge').log( + ('per-%s checks unavailable on %s, showing repo CI'):format(kind, f.name) + ) + M.ci(f) + end + end, + manage = function() + M.pr_manage(f, num) + end, } - - ---@type table - 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 - 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]]() - end - end, + { + 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,83 +248,75 @@ function M.checks(f, num, filter, cached_checks) pending = 'running', } - local check_actions = build_actions('ci', { - { - name = 'log', - fn = function(selected) - local c = get_check(selected) - if not c then - return - end - local run_id = (c.link or ''):match('/actions/runs/(%d+)') - if not run_id then - return - end - forge_mod.log_now('fetching check logs...') - local bucket = (c.bucket or ''):lower() - local cmd - if bucket == 'pending' then - cmd = f:check_tail_cmd(run_id) - else - cmd = f:check_log_cmd(run_id, bucket == 'fail') - end - vim.cmd('noautocmd botright new') - vim.fn.termopen(cmd) - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes('G', true, false, true), - 'n', - false - ) - if c.link then - vim.b.forge_check_url = c.link - end - end, - }, - { - name = 'browse', - fn = function(selected) - local c = get_check(selected) - if c and c.link then - vim.ui.open(c.link) - end - end, - }, - { - name = 'failed', - fn = function() - M.checks(f, num, 'fail', checks) - end, - }, - { - name = 'passed', - fn = function() - M.checks(f, num, 'pass', checks) - end, - }, - { - name = 'running', - fn = function() - M.checks(f, num, 'pending', checks) - end, - }, - { - name = 'all', - fn = function() - M.checks(f, num, 'all', checks) - end, - }, - }) - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, + picker.pick({ prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--with-nth'] = '2..', - ['--delimiter'] = '\t', + entries = entries, + actions = { + { + name = 'log', + 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 + end + forge_mod.log_now('fetching check logs...') + local bucket = (c.bucket or ''):lower() + local cmd + if bucket == 'pending' then + cmd = f:check_tail_cmd(run_id) + else + cmd = f:check_log_cmd(run_id, bucket == 'fail') + end + vim.cmd('noautocmd botright new') + vim.fn.termopen(cmd) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('G', true, false, true), + 'n', + false + ) + if c.link then + vim.b.forge_check_url = c.link + end + end, + }, + { + name = 'browse', + fn = function(entry) + if entry and entry.value.link then + vim.ui.open(entry.value.link) + end + end, + }, + { + name = 'failed', + fn = function() + M.checks(f, num, 'fail', checks) + end, + }, + { + name = 'passed', + fn = function() + M.checks(f, num, 'pass', checks) + end, + }, + { + name = 'running', + fn = function() + M.checks(f, num, 'pending', checks) + end, + }, + { + name = 'all', + fn = function() + M.checks(f, num, 'all', checks) + end, + }, }, - 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,82 +349,70 @@ 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', { - { - name = 'log', - fn = function(selected) - local run = get_run(selected) - if not run then - return - end - forge_mod.log_now('fetching CI/CD logs...') - local s = run.status:lower() - local cmd - if s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then - cmd = f:run_tail_cmd(run.id) - elseif s == 'failure' or s == 'failed' then - cmd = f:run_log_cmd(run.id, true) - else - cmd = f:run_log_cmd(run.id, false) - end - vim.cmd('noautocmd botright new') - vim.fn.termopen(cmd) - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes('G', true, false, true), - 'n', - false - ) - if run.url ~= '' then - vim.b.forge_run_url = run.url - end - end, - }, - { - name = 'browse', - fn = function(selected) - local run = get_run(selected) - if run and run.url ~= '' then - vim.ui.open(run.url) - end - end, - }, - { - name = 'refresh', - fn = function() - M.ci(f, branch) - end, - }, - }) - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, + picker.pick({ prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--with-nth'] = '2..', - ['--delimiter'] = '\t', + entries = entries, + actions = { + { + name = 'log', + 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 + if s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then + cmd = f:run_tail_cmd(run.id) + elseif s == 'failure' or s == 'failed' then + cmd = f:run_log_cmd(run.id, true) + else + cmd = f:run_log_cmd(run.id, false) + end + vim.cmd('noautocmd botright new') + vim.fn.termopen(cmd) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('G', true, false, true), + 'n', + false + ) + if run.url ~= '' then + vim.b.forge_run_url = run.url + end + end, + }, + { + name = 'browse', + fn = function(entry) + if entry and entry.value.url ~= '' then + vim.ui.open(entry.value.url) + end + end, + }, + { + name = 'refresh', + fn = function() + M.ci(f, branch) + end, + }, }, - 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 == '' then + return 'default' + end + return key:gsub('', 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,64 +469,54 @@ function M.commits(f) end end - local defs = { - { - name = 'checkout', - fn = function(selected) - with_sha(selected, function(sha) - forge_mod.log_now('checking out ' .. sha .. '...') - vim.system({ 'git', 'checkout', sha }, { text = true }, function(result) - vim.schedule(function() - 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 - ) - end - vim.cmd.redraw() - end) + 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) + vim.schedule(function() + 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) + end + vim.cmd.redraw() end) end) - end, - }, - { - name = 'diff', - fn = function(selected) - with_sha(selected, function(sha) - local range = sha .. '^..' .. sha - review.start(range) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(range) - end - forge_mod.log_now('reviewing ' .. sha) - end) - end, - }, - { - name = 'browse', - fn = function(selected) - with_sha(selected, function(sha) - if f then - f:browse_commit(sha) - end - end) - end, - }, - { - name = 'yank', - fn = 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 + 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) + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(range) + end + forge_mod.log_now('reviewing ' .. sha) + end) + 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 + 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 + 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,41 +535,54 @@ function M.branches(f) local forge_mod = require('forge') local review = require('forge.review') - local defs = { - { - name = 'diff', - fn = function(selected) - if not selected[1] then - return - end - local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') - if not br then - return - end - review.start(br) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(br) - end - forge_mod.log_now('reviewing ' .. br) - end, - }, - { - name = 'browse', - fn = function(selected) - if not selected[1] then - return - end - local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') - if br and f then - f:browse_branch(br) - end - end, - }, - } + if picker.backend() ~= 'fzf-lua' then + vim.notify('[forge]: branches picker requires fzf-lua', vim.log.levels.WARN) + return + end - local branch_actions = build_actions('branches', defs) - require('fzf-lua').git_branches({ actions = branch_actions }) + local function to_fzf_key(key) + if key == '' then + return 'default' + end + return key:gsub('', 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 + local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') + if not br then + return + end + review.start(br) + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(br) + end + forge_mod.log_now('reviewing ' .. br) + end + end + if keys.browse then + fzf_actions[to_fzf_key(keys.browse)] = function(selected) + if not selected[1] then + return + end + local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') + if br and f then + f:browse_branch(br) + end + end + end + + require('fzf-lua').git_branches({ actions = fzf_actions }) end ---@param state 'all'|'open'|'closed' @@ -669,95 +596,89 @@ 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', { - { - name = 'checkout', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['checkout']() - end) - end, - }, - { - name = 'diff', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['diff']() - end) - end, - }, - { - name = 'worktree', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['worktree']() - end) - end, - }, - { - name = 'ci', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['ci']() - end) - end, - }, - { - name = 'browse', - fn = function(selected) - with_pr_num(selected, function(num) - f:view_web(cli_kind, num) - end) - end, - }, - { - name = 'manage', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['manage']() - end) - end, - }, - { - name = 'create', - fn = function() - forge_mod.create_pr() - end, - }, - { - name = 'filter', - fn = function() - M.pr(next_state, f) - end, - }, - { - name = 'refresh', - fn = function() - forge_mod.clear_list(cache_key) - M.pr(state, f) - end, - }, - }) - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, + picker.pick({ prompt = ('%s (%s)> '):format(f.labels.pr, state), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', + entries = entries, + actions = { + { + name = 'checkout', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).checkout() + end + end, + }, + { + name = 'diff', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).diff() + end + end, + }, + { + name = 'worktree', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).worktree() + end + end, + }, + { + name = 'ci', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).ci() + end + end, + }, + { + name = 'browse', + fn = function(entry) + if entry then + f:view_web(cli_kind, entry.value) + end + end, + }, + { + name = 'manage', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).manage() + end + end, + }, + { + name = 'create', + fn = function() + forge_mod.create_pr() + end, + }, + { + name = 'filter', + fn = function() + M.pr(next_state, f) + end, + }, + { + name = 'refresh', + fn = function() + forge_mod.clear_list(cache_key) + M.pr(state, f) + end, + }, }, - actions = list_actions, + picker_name = 'pr', }) end @@ -795,60 +716,53 @@ 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', { - { - name = 'browse', - fn = function(selected) - with_issue_num(selected, function(num) - f:view_web(cli_kind, num) - end) - end, - }, - { - name = 'close', - fn = function(selected) - with_issue_num(selected, function(num) - issue_toggle_state(f, num, state_map[num] ~= false) - end) - end, - }, - { - name = 'filter', - fn = function() - M.issue(next_state, f) - end, - }, - { - name = 'refresh', - fn = function() - forge_mod.clear_list(cache_key) - M.issue(state, f) - end, - }, - }) - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, + picker.pick({ prompt = ('%s (%s)> '):format(f.labels.issue, state), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', + entries = entries, + actions = { + { + name = 'browse', + fn = function(entry) + if entry then + f:view_web(cli_kind, entry.value) + end + end, + }, + { + name = 'close', + fn = function(entry) + if entry then + issue_toggle_state(f, entry.value, state_map[entry.value] ~= false) + end + end, + }, + { + name = 'filter', + fn = function() + M.issue(next_state, f) + end, + }, + { + name = 'refresh', + fn = function() + forge_mod.clear_list(cache_key) + M.issue(state, f) + end, + }, }, - actions = issue_actions, + picker_name = 'issue', }) end @@ -891,7 +805,7 @@ end ---@param num string ---@return table 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() - require('fzf-lua').git_worktrees() + 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]]() - end - end, + { + name = 'default', + fn = function(entry) + if entry and action_map[entry.value] then + action_map[entry.value]() + end + end, + }, }, + picker_name = '_menu', }) end