mirror of
https://github.com/folke/snacks.nvim
synced 2025-12-23 08:47:57 +00:00
feat(gh): you can now use Snacks.picker.gh_actions() directly to see actions for the checked out PR
This commit is contained in:
parent
07c569dfd5
commit
d0d10f6d13
7 changed files with 214 additions and 52 deletions
|
|
@ -80,12 +80,11 @@ M.actions.gh_actions = {
|
|||
return Snacks.picker.actions.jump(ctx.picker, item, ctx.action)
|
||||
end
|
||||
local actions = M.get_actions(item)
|
||||
actions.gh_actions = nil -- remove self to avoid recursion
|
||||
Snacks.picker.pick({
|
||||
source = "gh_actions",
|
||||
actions.gh_actions = nil -- remove this action
|
||||
actions.gh_perform_action = nil -- remove this action
|
||||
Snacks.picker.gh_actions({
|
||||
item = item,
|
||||
layout = {
|
||||
preset = "select",
|
||||
layout = { max_width = 80 },
|
||||
config = function(layout)
|
||||
-- Fit list height to number of items, up to 10
|
||||
for _, box in ipairs(layout.layout) do
|
||||
|
|
@ -95,35 +94,6 @@ M.actions.gh_actions = {
|
|||
end
|
||||
end,
|
||||
},
|
||||
main = { current = true },
|
||||
title = ("Actions for %s #%d"):format(item.type, item.number),
|
||||
finder = function()
|
||||
local items = {} ---@type snacks.picker.finder.Item[]
|
||||
for name, action in pairs(actions) do
|
||||
---@class snacks.picker.gh.Action: snacks.picker.finder.Item
|
||||
items[#items + 1] = {
|
||||
text = Snacks.picker.util.text(action, { "name", "desc" }),
|
||||
file = item.uri,
|
||||
name = name,
|
||||
item = item,
|
||||
desc = action.desc or name,
|
||||
action = action,
|
||||
}
|
||||
end
|
||||
table.sort(items, function(a, b)
|
||||
local pa = a.action.priority or 0
|
||||
local pb = b.action.priority or 0
|
||||
if pa ~= pb then
|
||||
return pa > pb
|
||||
end
|
||||
return a.desc < b.desc
|
||||
end)
|
||||
for i, it in ipairs(items) do
|
||||
it.text = ("%d. %s"):format(i, it.text)
|
||||
end
|
||||
return items
|
||||
end,
|
||||
format = "gh_format_action",
|
||||
---@param it snacks.picker.gh.Action
|
||||
confirm = function(picker, it, action)
|
||||
if not it then
|
||||
|
|
@ -140,6 +110,16 @@ M.actions.gh_actions = {
|
|||
end,
|
||||
}
|
||||
|
||||
M.actions.gh_perform_action = {
|
||||
action = function(item, ctx)
|
||||
if not item then
|
||||
return
|
||||
end
|
||||
item.action.action(item.item, ctx)
|
||||
ctx.picker:close()
|
||||
end,
|
||||
}
|
||||
|
||||
M.actions.gh_browse = {
|
||||
desc = "Open in web browser",
|
||||
title = "Open {type} #{number} in web browser",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ local M = {}
|
|||
|
||||
---@type table<string, snacks.picker.gh.Item>
|
||||
local cache = setmetatable({}, { __mode = "v" })
|
||||
local pr_cache = {} ---@type table<string, snacks.picker.gh.Item>
|
||||
|
||||
---@type table<string, snacks.gh.api.Config|{}>
|
||||
local config = {
|
||||
|
|
@ -304,10 +305,10 @@ function M.list(what, cb, opts)
|
|||
})
|
||||
end
|
||||
|
||||
---@param item snacks.gh.api.View
|
||||
---@param cb fun(item?: snacks.picker.gh.Item, updated?: boolean)
|
||||
---@param item snacks.gh.api.View
|
||||
---@param opts? { fields?: string[], force?: boolean }
|
||||
function M.view(item, cb, opts)
|
||||
function M.view(cb, item, opts)
|
||||
opts = opts or {}
|
||||
local api_opts = get_opts(item.type, "view")
|
||||
if opts.fields then
|
||||
|
|
@ -455,4 +456,83 @@ function M.comments(item, cb)
|
|||
})
|
||||
end
|
||||
|
||||
function M.get_branch()
|
||||
local branch = vim.fn.system({ "git", "branch", "--show-current" }):gsub("\n", "")
|
||||
|
||||
-- Get all config in one call
|
||||
local git_config = vim.fn
|
||||
.system({
|
||||
"git",
|
||||
"config",
|
||||
"--get-regexp",
|
||||
("^(branch\\.%s\\.|remote\\.)"):format(branch),
|
||||
})
|
||||
:gsub("\n$", "")
|
||||
|
||||
local cfg = {} ---@type table<string, string>
|
||||
for _, line in ipairs(vim.split(git_config, "\n")) do
|
||||
local key, value = line:match("^([^%s]+)%s+(.+)$")
|
||||
if key then
|
||||
cfg[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Extract values
|
||||
local remote = cfg[("branch.%s.remote"):format(branch)] or ""
|
||||
local merge = cfg[("branch.%s.merge"):format(branch)] or ""
|
||||
local origin_url = cfg["remote.origin.url"] or ""
|
||||
|
||||
-- Get fork URL (either named remote or direct URL)
|
||||
local url = (remote:match("^https://") or remote:match("^git@")) and remote
|
||||
or cfg[("remote.%s.url"):format(remote)]
|
||||
or remote
|
||||
|
||||
-- Parse author from fork URL
|
||||
local author ---@type string?
|
||||
if url ~= "" then
|
||||
---@type string?
|
||||
local owner_repo = url:match("github%.com[:/](.+/.+)%.git") or url:match("github%.com[:/](.+/.+)$")
|
||||
author = owner_repo and owner_repo:match("^([^/]+)/") or nil
|
||||
end
|
||||
|
||||
-- Parse repo from origin
|
||||
local repo = origin_url:match("github%.com[:/](.+/.+)%.git") or origin_url:match("github%.com[:/](.+/.+)$")
|
||||
|
||||
-- Parse head from merge ref
|
||||
local head = merge:match("^refs/heads/(.+)$") or branch
|
||||
|
||||
-- Get base branch (default branch from origin)
|
||||
local base = vim.fn.system({ "git", "symbolic-ref", "refs/remotes/origin/HEAD" }):gsub("\n", "")
|
||||
base = base:match("refs/remotes/origin/(.+)") or "main"
|
||||
|
||||
---@type snacks.gh.api.Branch
|
||||
return {
|
||||
url = url,
|
||||
author = author,
|
||||
repo = repo,
|
||||
branch = branch,
|
||||
head = head,
|
||||
base = base,
|
||||
}
|
||||
end
|
||||
|
||||
---@param cb fun(item?: snacks.picker.gh.Item)
|
||||
function M.current_pr(cb)
|
||||
local branch = M.get_branch()
|
||||
local key = string.format("%s:%s/%s->%s", branch.author or "", branch.repo or "", branch.head, branch.base)
|
||||
if pr_cache[key] then
|
||||
return cb(pr_cache[key])
|
||||
end
|
||||
return M.list("pr", function(items)
|
||||
pr_cache[key] = items and items[1] or nil
|
||||
cb(pr_cache[key])
|
||||
end, {
|
||||
author = branch.author,
|
||||
head = branch.head,
|
||||
base = branch.base,
|
||||
repo = branch.repo,
|
||||
limit = 1,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ function M:render(opts)
|
|||
self:wo()
|
||||
|
||||
local spinner ---@type snacks.util.Spinner?
|
||||
local proc = Api.view(self.item, function(it, updated)
|
||||
local proc = Api.view(function(it, updated)
|
||||
vim.schedule(function()
|
||||
if not self:valid() then
|
||||
return
|
||||
|
|
@ -112,7 +112,7 @@ function M:render(opts)
|
|||
self:keys()
|
||||
end
|
||||
end)
|
||||
end, { force = opts.force })
|
||||
end, self.item, { force = opts.force })
|
||||
|
||||
-- initial render (is partial if proc is running)
|
||||
if Item.is(self.item) then
|
||||
|
|
|
|||
|
|
@ -175,3 +175,11 @@
|
|||
---@field closed? number
|
||||
---@field merged? number
|
||||
---@field draft? boolean
|
||||
|
||||
---@class snacks.gh.api.Branch
|
||||
---@field url string URL of the remote branch
|
||||
---@field author string owner of the remote branch
|
||||
---@field repo string owner/name format
|
||||
---@field branch string local branch name
|
||||
---@field base string branch we want to merge into
|
||||
---@field head string branch we want to merge from
|
||||
|
|
|
|||
|
|
@ -309,6 +309,20 @@ M.gh_labels = {
|
|||
format = "gh_format_label",
|
||||
}
|
||||
|
||||
---@class snacks.picker.gh.actions.Config: snacks.picker.Config
|
||||
---@field number number issue or PR number
|
||||
---@field repo string GitHub repository (owner/repo). Defaults to current git repo
|
||||
---@field type "issue" | "pr"
|
||||
---@field item? snacks.picker.gh.Item
|
||||
M.gh_actions = {
|
||||
layout = { preset = "select", layout = { max_width = 50 } },
|
||||
title = " Actions",
|
||||
main = { current = true },
|
||||
finder = "gh_get_actions",
|
||||
format = "gh_format_action",
|
||||
confirm = "gh_perform_action",
|
||||
}
|
||||
|
||||
--- Git arguments are use like this:
|
||||
--- * git [<cmd_args>] <cmd> [<args>]
|
||||
--- * cmd may be `status`, `log`, `diff`, etc.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
local Actions = require("snacks.gh.actions")
|
||||
local Api = require("snacks.gh.api")
|
||||
local Actions = require("snacks.gh.actions").actions
|
||||
|
||||
local M = {}
|
||||
|
||||
|
|
@ -8,15 +8,15 @@ M.actions = setmetatable({}, {
|
|||
if type(k) ~= "string" then
|
||||
return
|
||||
end
|
||||
if not Actions[k] then
|
||||
if not Actions.actions[k] then
|
||||
return nil
|
||||
end
|
||||
---@type snacks.picker.Action
|
||||
local action = {
|
||||
desc = Actions[k].desc,
|
||||
desc = Actions.actions[k].desc,
|
||||
action = function(picker, item, action)
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
return Actions[k].action(item, {
|
||||
return Actions.actions[k].action(item, {
|
||||
picker = picker,
|
||||
items = picker:selected({ fallback = true }),
|
||||
action = action,
|
||||
|
|
@ -44,7 +44,7 @@ function M.gh(opts, ctx)
|
|||
end
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.Config
|
||||
---@param opts snacks.picker.gh.issue.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.issue(opts, ctx)
|
||||
return M.gh(
|
||||
|
|
@ -55,7 +55,7 @@ function M.issue(opts, ctx)
|
|||
)
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.Config
|
||||
---@param opts snacks.picker.gh.pr.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.pr(opts, ctx)
|
||||
return M.gh(
|
||||
|
|
@ -66,6 +66,81 @@ function M.pr(opts, ctx)
|
|||
)
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.gh.actions.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.get_actions(opts, ctx)
|
||||
opts = opts or {}
|
||||
local proc ---@type snacks.spawn.Proc?
|
||||
if not opts.item and not opts.number then
|
||||
proc = Api.current_pr(function(pr)
|
||||
opts.item = pr
|
||||
end)
|
||||
end
|
||||
---@async
|
||||
return function(cb)
|
||||
if proc then
|
||||
proc:wait()
|
||||
end
|
||||
local item = opts.item
|
||||
|
||||
if not item then
|
||||
local required = { "type", "repo", "number" }
|
||||
local missing = vim.tbl_filter(function(field)
|
||||
return opts[field] == nil
|
||||
end, required) ---@type string[]
|
||||
if #missing > 0 then
|
||||
Snacks.notify.error({
|
||||
"Missing required options for `Snacks.picker.gh_actions()`:",
|
||||
"- `" .. table.concat(missing, ", ") .. "`",
|
||||
"",
|
||||
"Either provide the fields, or run in a git repo with a **current PR**.",
|
||||
}, { title = "Snacks Picker GH Actions" })
|
||||
return
|
||||
end
|
||||
item = Api.get({ type = opts.type or "pr", repo = opts.repo, number = opts.number })
|
||||
proc = Api.view(function(it)
|
||||
item = it
|
||||
end, item)
|
||||
|
||||
if proc then
|
||||
proc:wait()
|
||||
end
|
||||
if not item then
|
||||
Snacks.notify.error("snacks.picker.gh.get_actions: Failed to get item")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local actions = Actions.get_actions(item)
|
||||
actions.gh_actions = nil -- remove this action
|
||||
actions.gh_perform_action = nil -- remove this action
|
||||
local items = {} ---@type snacks.picker.finder.Item[]
|
||||
for name, action in pairs(actions) do
|
||||
---@class snacks.picker.gh.Action: snacks.picker.finder.Item
|
||||
items[#items + 1] = {
|
||||
text = Snacks.picker.util.text(action, { "name", "desc" }),
|
||||
file = item.uri,
|
||||
name = name,
|
||||
item = item,
|
||||
desc = action.desc or name,
|
||||
action = action,
|
||||
}
|
||||
end
|
||||
table.sort(items, function(a, b)
|
||||
local pa = a.action.priority or 0
|
||||
local pb = b.action.priority or 0
|
||||
if pa ~= pb then
|
||||
return pa > pb
|
||||
end
|
||||
return a.desc < b.desc
|
||||
end)
|
||||
for i, it in ipairs(items) do
|
||||
it.text = ("%d. %s"):format(i, it.text)
|
||||
cb(it)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.gh.diff.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.diff(opts, ctx)
|
||||
|
|
|
|||
|
|
@ -105,8 +105,20 @@ function Proc:debug(opts)
|
|||
return Snacks.debug.cmd(opts)
|
||||
end
|
||||
|
||||
function Proc:setup_async()
|
||||
self.async = Async.running()
|
||||
if self.async then
|
||||
self.async:on("abort", function()
|
||||
if self:running() then
|
||||
self:kill()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
---@async
|
||||
function Proc:wait()
|
||||
self:setup_async()
|
||||
assert(self.async, "Not in an async context")
|
||||
assert(self.async == Async.running(), "Not in the current async context")
|
||||
while not self.did_exit or self:running() do
|
||||
|
|
@ -120,14 +132,7 @@ function Proc:run()
|
|||
return self:on_exit()
|
||||
end
|
||||
|
||||
self.async = Async.running()
|
||||
if self.async then
|
||||
self.async:on("abort", function()
|
||||
if self:running() then
|
||||
self:kill()
|
||||
end
|
||||
end)
|
||||
end
|
||||
self:setup_async()
|
||||
|
||||
self.stdout = assert(uv.new_pipe())
|
||||
self.stderr = assert(uv.new_pipe())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue