mirror of
https://github.com/folke/snacks.nvim
synced 2025-12-23 08:47:57 +00:00
feat(gh): new gh (GitHub cli) integration
## Description <!-- Describe the big picture of your changes to communicate to the maintainers why we should accept this pull request. --> ## Related Issue(s) <!-- If this PR fixes any issues, please link to the issue here. - Fixes #<issue_number> --> ## Screenshots <!-- Add screenshots of the changes if applicable. -->
This commit is contained in:
parent
9ad41782ec
commit
85b8ec2109
28 changed files with 2732 additions and 55 deletions
|
|
@ -62,9 +62,7 @@ function M.refresh()
|
|||
local pickers = Snacks.picker.get({ source = "explorer", tab = false })
|
||||
for _, picker in ipairs(pickers) do
|
||||
if picker and not picker.closed and Tree:is_dirty(picker:cwd(), picker.opts) then
|
||||
if not picker.list.target then
|
||||
picker.list:set_target()
|
||||
end
|
||||
picker.list:set_target()
|
||||
vim.schedule(function()
|
||||
if not picker or picker.closed then
|
||||
return
|
||||
|
|
|
|||
688
lua/snacks/gh/actions.lua
Normal file
688
lua/snacks/gh/actions.lua
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
local Api = require("snacks.gh.api")
|
||||
local config = require("snacks.gh").config()
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class snacks.gh.action.ctx
|
||||
---@field items snacks.picker.gh.Item[]
|
||||
---@field picker? snacks.Picker
|
||||
---@field action? snacks.picker.Action
|
||||
|
||||
---@class snacks.gh.cli.Action.ctx
|
||||
---@field item snacks.picker.gh.Item
|
||||
---@field args string[]
|
||||
---@field opts snacks.gh.cli.Action
|
||||
---@field picker? snacks.Picker
|
||||
---@field scratch? snacks.win
|
||||
---@field input? string
|
||||
|
||||
---@alias snacks.gh.action.fn fun(item?: snacks.picker.gh.Item, ctx: snacks.gh.action.ctx)
|
||||
|
||||
---@class snacks.gh.Action
|
||||
---@field action snacks.gh.action.fn
|
||||
---@field desc? string
|
||||
---@field name? string
|
||||
---@field priority? number
|
||||
---@field title? string -- for items
|
||||
---@field type? "pr" | "issue"
|
||||
---@field enabled? fun(item: snacks.picker.gh.Item): boolean
|
||||
|
||||
---@class snacks.gh.actions: {[string]:snacks.gh.Action}
|
||||
M.actions = setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
if type(key) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local action = M.cli_actions[key]
|
||||
if action then
|
||||
local ret = M.cli_action(action)
|
||||
rawset(M.actions, key, ret)
|
||||
return ret
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
M.actions.gh_diff = {
|
||||
desc = "View PR diff",
|
||||
icon = " ",
|
||||
priority = 100,
|
||||
type = "pr",
|
||||
title = "View diff for PR #{number}",
|
||||
action = function(item, ctx)
|
||||
if not item then
|
||||
return
|
||||
end
|
||||
Snacks.picker.gh_diff({
|
||||
show_delay = 0,
|
||||
repo = item.repo,
|
||||
pr = item.number,
|
||||
})
|
||||
end,
|
||||
}
|
||||
|
||||
M.actions.gh_open = {
|
||||
desc = "Open in buffer",
|
||||
icon = " ",
|
||||
priority = 100,
|
||||
title = "Open {type} #{number} in buffer",
|
||||
action = function(item, ctx)
|
||||
if ctx.picker then
|
||||
return Snacks.picker.actions.jump(ctx.picker, item, ctx.action)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
M.actions.gh_actions = {
|
||||
desc = "Show available actions",
|
||||
action = function(item, ctx)
|
||||
-- NOTE: this forwards split/vsplit/tab/drop actions to jump
|
||||
if ctx.action and ctx.action.cmd then
|
||||
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",
|
||||
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
|
||||
if box.win == "list" and not box.height then
|
||||
box.height = math.max(math.min(vim.tbl_count(actions), vim.o.lines * 0.8 - 10), 3)
|
||||
end
|
||||
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)
|
||||
return items
|
||||
end,
|
||||
format = "gh_format_action",
|
||||
---@param it snacks.picker.gh.Action
|
||||
confirm = function(picker, it, action)
|
||||
if not it then
|
||||
return
|
||||
end
|
||||
ctx.action = action
|
||||
if ctx.picker then
|
||||
ctx.picker:focus()
|
||||
end
|
||||
it.action.action(item, ctx)
|
||||
picker:close()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
}
|
||||
|
||||
M.actions.gh_browse = {
|
||||
desc = "Open in web browser",
|
||||
title = "Open {type} #{number} in web browser",
|
||||
icon = " ",
|
||||
action = function(_, ctx)
|
||||
for _, item in ipairs(ctx.items) do
|
||||
Api.cmd(function()
|
||||
Snacks.notify.info(("Opened #%s in web browser"):format(item.number))
|
||||
end, {
|
||||
args = { item.type, "view", tostring(item.number), "--web" },
|
||||
repo = item.repo,
|
||||
})
|
||||
end
|
||||
if ctx.picker then
|
||||
ctx.picker.list:set_selected() -- clear selection
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
M.actions.gh_react = {
|
||||
desc = "Add reaction",
|
||||
icon = " ",
|
||||
action = function(item, ctx)
|
||||
local reactions = { "+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes" }
|
||||
Snacks.picker.pick("gh_reactions", {
|
||||
number = item.number,
|
||||
repo = item.repo,
|
||||
layout = {
|
||||
config = function(layout)
|
||||
-- Fit list height to number of items, up to 10
|
||||
for _, box in ipairs(layout.layout) do
|
||||
if box.win == "list" and not box.height then
|
||||
box.height = math.max(math.min(#reactions, vim.o.lines * 0.8 - 10), 3)
|
||||
end
|
||||
end
|
||||
end,
|
||||
},
|
||||
confirm = function(picker)
|
||||
local items = picker:selected({ fallback = true })
|
||||
for i, it in ipairs(items) do
|
||||
if it.added then
|
||||
M.run(item, {
|
||||
api = {
|
||||
endpoint = "/repos/{repo}/issues/{number}/reactions/" .. it.id,
|
||||
method = "DELETE",
|
||||
},
|
||||
refresh = i == #items,
|
||||
}, ctx)
|
||||
else
|
||||
M.run(item, {
|
||||
api = {
|
||||
endpoint = "/repos/{repo}/issues/{number}/reactions",
|
||||
fields = { content = it.reaction },
|
||||
},
|
||||
refresh = i == #items,
|
||||
}, ctx)
|
||||
end
|
||||
end
|
||||
picker:close()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
}
|
||||
|
||||
M.actions.gh_label = {
|
||||
desc = "Add/Remove labels",
|
||||
icon = " ",
|
||||
action = function(item, ctx)
|
||||
Snacks.picker.pick("gh_labels", {
|
||||
number = item.number,
|
||||
repo = item.repo,
|
||||
type = item.type,
|
||||
confirm = function(picker)
|
||||
local labels = {} ---@type table<string, boolean>
|
||||
for _, label in ipairs(item.item.labels or {}) do
|
||||
labels[label.name] = true
|
||||
end
|
||||
for _, it in ipairs(picker:selected({ fallback = true })) do
|
||||
labels[it.label] = not it.added or nil
|
||||
end
|
||||
M.run(item, {
|
||||
api = {
|
||||
endpoint = "/repos/{repo}/issues/{number}/labels",
|
||||
method = "PUT",
|
||||
input = vim.fn.json_encode({ labels = vim.tbl_keys(labels) }),
|
||||
},
|
||||
}, ctx)
|
||||
picker:close()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
}
|
||||
|
||||
M.actions.gh_yank = {
|
||||
desc = "Yank URL(s) to clipboard",
|
||||
icon = " ",
|
||||
action = function(_, ctx)
|
||||
if vim.fn.mode():find("^[vV]") and ctx.picker then
|
||||
ctx.picker.list:select()
|
||||
end
|
||||
---@param it snacks.picker.gh.Item
|
||||
local urls = vim.tbl_map(function(it)
|
||||
return it.url
|
||||
end, ctx.items)
|
||||
if ctx.picker then
|
||||
ctx.picker.list:set_selected() -- clear selection
|
||||
end
|
||||
local value = table.concat(urls, "\n")
|
||||
vim.fn.setreg(vim.v.register or "+", value, "l")
|
||||
Snacks.notify.info("Yanked " .. #urls .. " URL(s)")
|
||||
end,
|
||||
}
|
||||
|
||||
---@type table<string, snacks.gh.cli.Action>
|
||||
M.cli_actions = {
|
||||
gh_comment = {
|
||||
cmd = "comment",
|
||||
icon = " ",
|
||||
title = "Comment on {type} #{number}",
|
||||
success = "Commented on {type} #{number}",
|
||||
edit = "body-file",
|
||||
},
|
||||
gh_checkout = {
|
||||
cmd = "checkout",
|
||||
icon = " ",
|
||||
type = "pr",
|
||||
confirm = "Are you sure you want to checkout PR #{number}?",
|
||||
title = "Checkout PR #{number}",
|
||||
success = "Checked out PR #{number}",
|
||||
},
|
||||
gh_close = {
|
||||
edit = "comment",
|
||||
icon = config.icons.crossmark,
|
||||
cmd = "close",
|
||||
title = "Close {type} #{number}",
|
||||
success = "Closed {type} #{number}",
|
||||
enabled = function(item)
|
||||
return item.state == "open"
|
||||
end,
|
||||
},
|
||||
gh_edit = {
|
||||
cmd = "edit",
|
||||
icon = " ",
|
||||
fields = {
|
||||
{ arg = "title", prop = "title", name = "Title" },
|
||||
},
|
||||
success = "Edited {type} #{number}",
|
||||
edit = "body-file",
|
||||
template = "{body}",
|
||||
title = "Edit {type} #{number}",
|
||||
},
|
||||
gh_squash = {
|
||||
cmd = "merge",
|
||||
icon = config.icons.pr.merged,
|
||||
type = "pr",
|
||||
success = "Squashed and merged PR #{number}",
|
||||
args = { "--squash" },
|
||||
fields = {
|
||||
{ arg = "subject", prop = "title", name = "Title" },
|
||||
},
|
||||
edit = "body-file",
|
||||
confirm = "Are you sure you want to squash and merge PR #{number}?",
|
||||
template = "{body}",
|
||||
title = "Squash and merge PR #{number}",
|
||||
enabled = function(item)
|
||||
return item.state == "open"
|
||||
end,
|
||||
},
|
||||
gh_merge_rebase = {
|
||||
cmd = "merge",
|
||||
icon = config.icons.pr.merged,
|
||||
type = "pr",
|
||||
success = "Rebased and merged PR #{number}",
|
||||
args = { "--rebase" },
|
||||
confirm = "Are you sure you want to rebase and merge PR #{number}?",
|
||||
title = "Rebase and merge PR #{number}",
|
||||
enabled = function(item)
|
||||
return item.state == "open"
|
||||
end,
|
||||
},
|
||||
gh_merge = {
|
||||
cmd = "merge",
|
||||
icon = config.icons.pr.merged,
|
||||
type = "pr",
|
||||
success = "Merged PR #{number}",
|
||||
args = { "--merge" },
|
||||
title = "Merge PR #{number}",
|
||||
confirm = "Are you sure you want to merge PR #{number}?",
|
||||
enabled = function(item)
|
||||
return item.state == "open"
|
||||
end,
|
||||
},
|
||||
gh_close_not_planned = {
|
||||
cmd = "close",
|
||||
icon = config.icons.crossmark,
|
||||
type = "issue",
|
||||
success = "Closed issue #{number} as not planned",
|
||||
args = { "--reason", "not planned" },
|
||||
edit = "comment",
|
||||
title = "Close issue #{number} as not planned",
|
||||
enabled = function(item)
|
||||
return item.state == "open"
|
||||
end,
|
||||
},
|
||||
gh_reopen = {
|
||||
cmd = "reopen",
|
||||
icon = " ",
|
||||
edit = "comment",
|
||||
title = "Reopen {type} #{number}",
|
||||
success = "Reopened {type} #{number}",
|
||||
enabled = function(item)
|
||||
return item.state == "closed"
|
||||
end,
|
||||
},
|
||||
gh_ready = {
|
||||
cmd = "ready",
|
||||
icon = config.icons.pr.open,
|
||||
type = "pr",
|
||||
title = "Mark PR #{number} as ready for review",
|
||||
success = "Marked PR #{number} as ready for review",
|
||||
enabled = function(item)
|
||||
return item.state == "open" and item.isDraft
|
||||
end,
|
||||
},
|
||||
gh_draft = {
|
||||
cmd = "ready",
|
||||
args = { "--undo" },
|
||||
icon = config.icons.pr.draft,
|
||||
type = "pr",
|
||||
title = "Mark PR #{number} as draft",
|
||||
success = "Marked PR #{number} as draft",
|
||||
enabled = function(item)
|
||||
return item.state == "open" and not item.isDraft
|
||||
end,
|
||||
},
|
||||
gh_approve = {
|
||||
cmd = "review",
|
||||
icon = config.icons.checkmark,
|
||||
type = "pr",
|
||||
args = { "--approve" },
|
||||
edit = "body-file", -- optional review summary
|
||||
title = "Review: approve PR #{number}",
|
||||
success = "Approved PR #{number}",
|
||||
enabled = function(item)
|
||||
return item.state == "open"
|
||||
end,
|
||||
},
|
||||
gh_request_changes = {
|
||||
cmd = "review",
|
||||
type = "pr",
|
||||
icon = " ",
|
||||
args = { "--request-changes" },
|
||||
edit = "body-file", -- explain what needs fixing
|
||||
title = "Review: request changes on PR #{number}",
|
||||
success = "Requested changes on PR #{number}",
|
||||
enabled = function(item)
|
||||
return item.state == "open"
|
||||
end,
|
||||
},
|
||||
gh_review = {
|
||||
cmd = "review",
|
||||
type = "pr",
|
||||
icon = " ",
|
||||
args = { "--comment" },
|
||||
edit = "body-file", -- general feedback
|
||||
title = "Review: comment on PR #{number}",
|
||||
success = "Commented on PR #{number}",
|
||||
enabled = function(item)
|
||||
return item.state == "open"
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
---@param opts snacks.gh.cli.Action
|
||||
function M.cli_action(opts)
|
||||
---@type snacks.gh.Action
|
||||
return setmetatable({
|
||||
desc = opts.desc or opts.title,
|
||||
---@type snacks.gh.action.fn
|
||||
action = function(item, ctx)
|
||||
M.run(item, opts, ctx)
|
||||
end,
|
||||
}, { __index = opts })
|
||||
end
|
||||
|
||||
---@param str string
|
||||
---@param ... table<string, any>
|
||||
function M.tpl(str, ...)
|
||||
local data = { ... }
|
||||
return Snacks.picker.util.tpl(
|
||||
str,
|
||||
setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
for _, d in ipairs(data) do
|
||||
if d[key] ~= nil then
|
||||
local ret = d[key]
|
||||
return ret == "pr" and "PR" or ret
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
---@param item snacks.picker.gh.Item
|
||||
function M.get_actions(item)
|
||||
local ret = {} ---@type table<string, snacks.gh.Action>
|
||||
local keys = vim.tbl_keys(M.actions) ---@type string[]
|
||||
vim.list_extend(keys, vim.tbl_keys(M.cli_actions))
|
||||
for _, name in ipairs(keys) do
|
||||
local action = M.actions[name]
|
||||
local enabled = action.type == nil or action.type == item.type
|
||||
enabled = enabled and (action.enabled == nil or action.enabled(item))
|
||||
if enabled then
|
||||
local a = setmetatable({}, { __index = action })
|
||||
local ca = M.cli_actions[name] or {}
|
||||
a.desc = a.title and M.tpl(a.title or name, item, ca) or a.desc
|
||||
a.name = name
|
||||
ret[name] = a
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end
|
||||
|
||||
--- Executes a gh cli action
|
||||
---@param item snacks.picker.gh.Item
|
||||
---@param action snacks.gh.cli.Action
|
||||
---@param ctx snacks.gh.action.ctx
|
||||
function M.run(item, action, ctx)
|
||||
local args = action.cmd and { item.type, action.cmd, tostring(item.number) } or {}
|
||||
vim.list_extend(args, action.args or {})
|
||||
if action.api then
|
||||
action.api.endpoint = M.tpl(action.api.endpoint, item, action)
|
||||
end
|
||||
---@type snacks.gh.cli.Action.ctx
|
||||
local cli_ctx = {
|
||||
item = item,
|
||||
args = args,
|
||||
opts = action,
|
||||
picker = ctx.picker,
|
||||
}
|
||||
if action.edit then
|
||||
return M.edit(cli_ctx)
|
||||
else
|
||||
return M._run(cli_ctx)
|
||||
end
|
||||
end
|
||||
|
||||
--- Parses frontmatter fields from body and appends them to ctx.args
|
||||
---@param body string
|
||||
---@param ctx snacks.gh.cli.Action.ctx
|
||||
function M.parse(body, ctx)
|
||||
if not ctx.opts.fields then
|
||||
return body
|
||||
end
|
||||
|
||||
local fields = {} ---@type table<string, snacks.gh.Field>
|
||||
for _, f in ipairs(ctx.opts.fields) do
|
||||
fields[f.name] = f
|
||||
end
|
||||
|
||||
local values = {} ---@type table<string, string>
|
||||
--- parse markdown frontmatter for fields
|
||||
body = body:gsub("^(%-%-%-\n.-\n%-%-%-\n%s*)", function(fm)
|
||||
fm = fm:gsub("^%-%-%-\n", ""):gsub("\n%-%-%-\n%s*$", "") --[[@as string]]
|
||||
local lines = vim.split(fm, "\n")
|
||||
for _, line in ipairs(lines) do
|
||||
local field, value = line:match("^(%w+):%s*(.-)%s*$")
|
||||
if field and fields[field] then
|
||||
values[field] = value
|
||||
else
|
||||
Snacks.notify.warn(("Unknown field `%s` in frontmatter"):format(field or line))
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end) --[[@as string]]
|
||||
|
||||
for _, field in ipairs(ctx.opts.fields) do
|
||||
local value = values[field.name]
|
||||
if value then
|
||||
if ctx.opts.api then
|
||||
ctx.opts.api.fields = ctx.opts.api.fields or {}
|
||||
ctx.opts.api.fields[field.arg] = value
|
||||
else
|
||||
vim.list_extend(ctx.args, { "--" .. field.arg, value })
|
||||
end
|
||||
else
|
||||
Snacks.notify.error(("Missing required field `%s` in frontmatter"):format(field.name))
|
||||
return
|
||||
end
|
||||
end
|
||||
return body
|
||||
end
|
||||
|
||||
--- Executes the action CLI command
|
||||
---@param ctx snacks.gh.cli.Action.ctx
|
||||
function M._run(ctx, force)
|
||||
if not force and ctx.opts.confirm then
|
||||
Snacks.picker.util.confirm(M.tpl(ctx.opts.confirm, ctx.item, ctx.opts), function()
|
||||
M._run(ctx, true)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
local spinner = require("snacks.picker.util.spinner").loading()
|
||||
local cb = function()
|
||||
vim.schedule(function()
|
||||
spinner:stop()
|
||||
|
||||
-- success message
|
||||
if ctx.opts.success then
|
||||
Snacks.notify.info(M.tpl(ctx.opts.success, ctx.item, ctx.opts))
|
||||
end
|
||||
|
||||
-- refresh item and picker
|
||||
if ctx.opts.refresh ~= false then
|
||||
vim.schedule(function()
|
||||
Api.refresh(ctx.item)
|
||||
if ctx.picker and not ctx.picker.closed then
|
||||
ctx.picker.list:set_selected()
|
||||
ctx.picker.list:set_target()
|
||||
ctx.picker:find()
|
||||
vim.cmd.startinsert()
|
||||
end
|
||||
end)
|
||||
if ctx.picker and not ctx.picker.closed then
|
||||
ctx.picker:focus()
|
||||
end
|
||||
end
|
||||
|
||||
-- clean up scratch buffer
|
||||
if ctx.scratch then
|
||||
local buf = assert(ctx.scratch.buf)
|
||||
local fname = vim.api.nvim_buf_get_name(buf)
|
||||
ctx.scratch:on("WinClosed", function()
|
||||
vim.schedule(function()
|
||||
pcall(vim.api.nvim_buf_delete, buf, { force = true })
|
||||
os.remove(fname)
|
||||
os.remove(fname .. ".meta")
|
||||
end)
|
||||
end, { buf = true })
|
||||
ctx.scratch:close()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
if ctx.opts.api then
|
||||
Api.request(
|
||||
cb,
|
||||
Snacks.config.merge(ctx.opts.api or {}, {
|
||||
input = ctx.input,
|
||||
args = ctx.args,
|
||||
on_error = function()
|
||||
spinner:stop()
|
||||
end,
|
||||
})
|
||||
)
|
||||
else
|
||||
Api.cmd(cb, {
|
||||
input = ctx.input,
|
||||
args = ctx.args,
|
||||
repo = ctx.item.repo or ctx.opts.repo,
|
||||
on_error = function()
|
||||
spinner:stop()
|
||||
end,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
--- Edit action body in scratch buffer
|
||||
---@param ctx snacks.gh.cli.Action.ctx
|
||||
function M.edit(ctx)
|
||||
---@param s? string
|
||||
local function tpl(s)
|
||||
return s and M.tpl(s, ctx.item, ctx.opts) or nil
|
||||
end
|
||||
|
||||
local template = ctx.opts.template or ""
|
||||
if not vim.tbl_isempty(ctx.opts.fields or {}) then
|
||||
local fm = { "---" }
|
||||
for _, f in ipairs(ctx.opts.fields) do
|
||||
fm[#fm + 1] = ("%s: {%s}"):format(f.name, f.prop)
|
||||
end
|
||||
fm[#fm + 1] = "---\n\n"
|
||||
template = table.concat(fm, "\n") .. template
|
||||
end
|
||||
|
||||
Snacks.scratch({
|
||||
ft = "markdown",
|
||||
icon = Snacks.gh.config().icons.logo,
|
||||
name = tpl(ctx.opts.title or "{cmd} {type} #{number}"),
|
||||
template = tpl(template),
|
||||
filekey = {
|
||||
cwd = false,
|
||||
branch = false,
|
||||
count = false,
|
||||
id = tpl("{repo}/{type}/{cmd}"),
|
||||
},
|
||||
win = {
|
||||
on_win = function()
|
||||
vim.schedule(function()
|
||||
vim.cmd.startinsert()
|
||||
end)
|
||||
end,
|
||||
keys = {
|
||||
submit = {
|
||||
"<c-s>",
|
||||
function(win)
|
||||
ctx.scratch = win
|
||||
M.submit(ctx)
|
||||
end,
|
||||
desc = "Submit",
|
||||
mode = { "n", "i" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
--- Submit edited body
|
||||
---@param ctx snacks.gh.cli.Action.ctx
|
||||
function M.submit(ctx)
|
||||
local edit = assert(ctx.opts.edit, "Submit called for action that doesn't need edit?")
|
||||
local win = assert(ctx.scratch, "Submit not called from scratch window?")
|
||||
ctx = setmetatable({
|
||||
args = vim.deepcopy(ctx.args),
|
||||
}, { __index = ctx }) -- shallow copy to avoid mutation
|
||||
local body = M.parse(win:text(), ctx)
|
||||
|
||||
if not body then
|
||||
return -- error already shown in M.parse
|
||||
end
|
||||
|
||||
if body:find("%S") then
|
||||
if edit == "body-file" then
|
||||
vim.list_extend(ctx.args, { "--body-file", "-" })
|
||||
ctx.input = body
|
||||
else
|
||||
vim.list_extend(ctx.args, { "--" .. edit, body })
|
||||
end
|
||||
end
|
||||
|
||||
vim.cmd.stopinsert()
|
||||
vim.schedule(function()
|
||||
M._run(ctx)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
317
lua/snacks/gh/api.lua
Normal file
317
lua/snacks/gh/api.lua
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
local Async = require("snacks.picker.util.async")
|
||||
local Item = require("snacks.gh.item")
|
||||
|
||||
---@class snacks.gh.api
|
||||
local M = {}
|
||||
|
||||
---@type table<string, snacks.picker.gh.Item>
|
||||
local cache = setmetatable({}, { __mode = "v" })
|
||||
|
||||
---@type table<string, snacks.gh.api.Config|{}>
|
||||
local config = {
|
||||
base = {
|
||||
list = {
|
||||
"author",
|
||||
"closedAt",
|
||||
"createdAt",
|
||||
"id",
|
||||
"body",
|
||||
"labels",
|
||||
"number",
|
||||
"reactionGroups",
|
||||
"state",
|
||||
"title",
|
||||
"updatedAt",
|
||||
"url",
|
||||
},
|
||||
view = { "comments" },
|
||||
text = { "author", "hash", "label", "title" },
|
||||
options = { "app", "assignee", "author", "jq", "label", "repo", "search", "state" },
|
||||
},
|
||||
api = {
|
||||
options = { "cache", "jq", "method", "paginate", "silent", "slurp" },
|
||||
},
|
||||
issue = {
|
||||
list = { "stateReason" },
|
||||
options = { "mention", "milestone" },
|
||||
---@param item snacks.picker.gh.Item
|
||||
transform = function(item)
|
||||
item.status = item.state == "closed" and item.state_reason or item.state
|
||||
return item
|
||||
end,
|
||||
},
|
||||
pr = {
|
||||
options = { "base", "draft" },
|
||||
list = {
|
||||
"mergedAt",
|
||||
"changedFiles",
|
||||
"mergeable",
|
||||
"mergeStateStatus",
|
||||
"isDraft",
|
||||
},
|
||||
view = {
|
||||
"additions",
|
||||
"baseRefName",
|
||||
"deletions",
|
||||
"headRefName",
|
||||
"mergedAt",
|
||||
"statusCheckRollup",
|
||||
},
|
||||
---@param item snacks.picker.gh.Item
|
||||
transform = function(item)
|
||||
item.status = item.draft and "draft" or item.state
|
||||
return item
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
---@param item snacks.gh.api.View
|
||||
local function cache_get(item)
|
||||
return cache[Item.to_uri(item)]
|
||||
end
|
||||
|
||||
---@param item snacks.picker.gh.Item
|
||||
local function cache_set(item)
|
||||
cache[item.uri] = item
|
||||
return item
|
||||
end
|
||||
|
||||
---@param what "issue" | "pr"
|
||||
---@param key "list" | "view"
|
||||
local function get_opts(what, key)
|
||||
local base = vim.deepcopy(config.base)
|
||||
local specific = vim.deepcopy(config[what] or {})
|
||||
base.type = what
|
||||
base.fields = vim.list_extend(base.list or {}, specific.list or {})
|
||||
if key ~= "list" then
|
||||
base.fields = vim.list_extend(base.fields, base[key] or {})
|
||||
base.fields = vim.list_extend(base.fields, specific[key] or {})
|
||||
end
|
||||
base.text = vim.list_extend(base.text, specific.text or {})
|
||||
base.options = vim.list_extend(base.options, specific.options or {})
|
||||
base.transform = specific.transform
|
||||
return base
|
||||
end
|
||||
|
||||
---@param args string[]
|
||||
---@param options string[]
|
||||
---@param opts table<string, string|boolean|nil>
|
||||
local function set_options(args, options, opts)
|
||||
for _, option in ipairs(options or {}) do
|
||||
local value = opts[option] ---@type string|boolean|nil
|
||||
if type(value) == "boolean" and value then
|
||||
args[#args + 1] = "--" .. option
|
||||
elseif value and value ~= "" then
|
||||
vim.list_extend(args, { "--" .. option, tostring(value) })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param cb fun(proc: snacks.spawn.Proc, data?: string)
|
||||
---@param opts snacks.gh.api.Cmd
|
||||
function M.cmd(cb, opts)
|
||||
local args = vim.deepcopy(opts.args)
|
||||
if opts.repo then
|
||||
vim.list_extend(args, { "--repo", opts.repo })
|
||||
end
|
||||
local Spawn = require("snacks.util.spawn")
|
||||
local async = Async.running()
|
||||
local ret ---@type snacks.spawn.Proc
|
||||
|
||||
if async then
|
||||
async:on("abort", function()
|
||||
if ret and ret:running() then
|
||||
ret:kill()
|
||||
end
|
||||
end)
|
||||
end
|
||||
ret = Spawn.new({
|
||||
cmd = "gh",
|
||||
args = args,
|
||||
input = opts.input,
|
||||
timeout = 10000,
|
||||
-- debug = true,
|
||||
on_exit = function(proc, err)
|
||||
if err then
|
||||
vim.schedule(function()
|
||||
if not proc.aborted then
|
||||
Snacks.debug.cmd({
|
||||
header = "GH Error",
|
||||
cmd = { "gh", unpack(args) },
|
||||
footer = proc:err(),
|
||||
level = vim.log.levels.ERROR,
|
||||
})
|
||||
if opts.on_error then
|
||||
opts.on_error(proc, proc:err())
|
||||
end
|
||||
end
|
||||
end)
|
||||
return
|
||||
end
|
||||
return cb(proc, not err and proc:out() or nil)
|
||||
end,
|
||||
})
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param opts snacks.gh.api.Cmd
|
||||
---@async
|
||||
function M.cmd_sync(opts)
|
||||
local ret ---@type any
|
||||
M.cmd(function(_, data)
|
||||
ret = data
|
||||
end, opts):wait()
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param cb fun(proc: snacks.spawn.Proc, data?: unknown)
|
||||
---@param opts snacks.gh.api.Fetch
|
||||
function M.fetch(cb, opts)
|
||||
local args = vim.deepcopy(opts.args)
|
||||
vim.list_extend(args, { "--json", table.concat(opts.fields, ",") })
|
||||
return M.cmd(function(proc, data)
|
||||
cb(proc, data and proc:json() or nil)
|
||||
end, {
|
||||
args = args,
|
||||
repo = opts.repo,
|
||||
})
|
||||
end
|
||||
|
||||
---@param opts snacks.gh.api.Fetch
|
||||
---@async
|
||||
function M.fetch_sync(opts)
|
||||
local ret ---@type any
|
||||
M.fetch(function(_, data)
|
||||
ret = data
|
||||
end, opts):wait()
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param cb fun(proc: snacks.spawn.Proc, data?: table)
|
||||
---@param opts snacks.gh.api.Api
|
||||
function M.request(cb, opts)
|
||||
local args = { "api", opts.endpoint }
|
||||
set_options(args, config.api.options or {}, opts)
|
||||
if opts.input then
|
||||
args[#args + 1] = "--input"
|
||||
args[#args + 1] = "-"
|
||||
end
|
||||
for k, v in pairs(opts.fields or {}) do
|
||||
args[#args + 1] = "--raw-field"
|
||||
args[#args + 1] = string.format("%s=%s", k, tostring(v))
|
||||
end
|
||||
for k, v in pairs(opts.header or {}) do
|
||||
args[#args + 1] = "--header"
|
||||
args[#args + 1] = string.format("%s: %s", k, tostring(v))
|
||||
end
|
||||
return M.cmd(function(proc, data)
|
||||
cb(proc, data and data:find("%S") and proc:json() or nil)
|
||||
end, {
|
||||
args = args,
|
||||
input = opts.input,
|
||||
on_error = opts.on_error,
|
||||
})
|
||||
end
|
||||
|
||||
---@param opts snacks.gh.api.Api
|
||||
---@async
|
||||
function M.request_sync(opts)
|
||||
local ret ---@type any
|
||||
M.request(function(_, data)
|
||||
ret = data
|
||||
end, opts):wait()
|
||||
return ret
|
||||
end
|
||||
|
||||
---@async
|
||||
function M.user()
|
||||
---@type snacks.gh.User
|
||||
return M.request_sync({
|
||||
endpoint = "/user",
|
||||
})
|
||||
end
|
||||
|
||||
---@param what "issue" | "pr"
|
||||
---@param cb fun(items?: snacks.picker.gh.Item[])
|
||||
---@param opts? snacks.picker.gh.Config
|
||||
function M.list(what, cb, opts)
|
||||
opts = opts or {}
|
||||
local api_opts = get_opts(what, "list")
|
||||
local args = { what, "list" }
|
||||
|
||||
vim.list_extend(args, { "--limit", tostring(opts.limit or 50) })
|
||||
set_options(args, api_opts.options, opts)
|
||||
|
||||
---@param data? snacks.gh.Item[]
|
||||
return M.fetch(function(_, data)
|
||||
if not data then
|
||||
return cb()
|
||||
end
|
||||
---@param item snacks.gh.Item
|
||||
return cb(vim.tbl_map(function(item)
|
||||
return cache_set(Item.new(item, api_opts))
|
||||
end, data))
|
||||
end, {
|
||||
args = args,
|
||||
fields = api_opts.fields,
|
||||
repo = opts.repo,
|
||||
})
|
||||
end
|
||||
|
||||
---@param item snacks.gh.api.View
|
||||
---@param cb fun(item?: snacks.picker.gh.Item, updated?: boolean)
|
||||
---@param opts? { fields?: string[], force?: boolean }
|
||||
function M.view(item, cb, opts)
|
||||
opts = opts or {}
|
||||
local api_opts = get_opts(item.type, "view")
|
||||
if opts.fields then
|
||||
api_opts.fields = vim.list_extend(api_opts.fields, opts.fields)
|
||||
end
|
||||
|
||||
item = not Item.is(item) and cache_get(item) or item
|
||||
local todo = Item.is(item) and item:need(api_opts.fields) or api_opts.fields
|
||||
if opts.force or item.dirty then
|
||||
todo = api_opts.fields
|
||||
item.dirty = false
|
||||
end
|
||||
|
||||
if #todo == 0 then
|
||||
cb(item, false)
|
||||
return
|
||||
end
|
||||
|
||||
local args = { item.type, "view", tostring(item.number) }
|
||||
---@param data? snacks.gh.Item
|
||||
return M.fetch(function(_, data)
|
||||
if not data then
|
||||
return cb()
|
||||
end
|
||||
item = Item.new(item, api_opts)
|
||||
item:update(data, todo)
|
||||
cb(cache_set(item), true)
|
||||
end, {
|
||||
args = args,
|
||||
fields = todo,
|
||||
repo = api_opts.repo,
|
||||
})
|
||||
end
|
||||
|
||||
---@param item snacks.gh.api.View
|
||||
function M.get(item)
|
||||
return not Item.is(item) and cache_get(item) or item
|
||||
end
|
||||
|
||||
---@param item snacks.picker.gh.Item
|
||||
function M.refresh(item)
|
||||
item.dirty = true
|
||||
cache_set(item)
|
||||
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.api.nvim_buf_is_loaded(buf) then
|
||||
if vim.api.nvim_buf_get_name(buf) == item.uri then
|
||||
require("snacks.gh.buf").attach(buf, item)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
243
lua/snacks/gh/buf.lua
Normal file
243
lua/snacks/gh/buf.lua
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
local Actions = require("snacks.gh.actions")
|
||||
local Api = require("snacks.gh.api")
|
||||
local Item = require("snacks.gh.item")
|
||||
local Render = require("snacks.gh.render")
|
||||
|
||||
---@class snacks.gh.Buf
|
||||
---@field buf number
|
||||
---@field opts snacks.gh.Config
|
||||
---@field item snacks.gh.api.View
|
||||
local M = {}
|
||||
M.__index = M
|
||||
|
||||
---@class vim.var_accessor
|
||||
---@field snacks_gh? { repo: string, type: string, number: number }
|
||||
|
||||
---@type table<number, snacks.gh.Buf>
|
||||
M.attached = {}
|
||||
local did_setup = false
|
||||
|
||||
---@param buf number
|
||||
---@param item snacks.gh.api.View
|
||||
function M.new(buf, item)
|
||||
local self = setmetatable({}, M)
|
||||
self.buf = buf
|
||||
self.item = item
|
||||
self.opts = vim.deepcopy(Snacks.gh.config())
|
||||
self.opts.bo = Snacks.config.merge({}, self.opts.bo, {
|
||||
buftype = "acwrite",
|
||||
swapfile = false,
|
||||
filetype = "markdown.gh",
|
||||
})
|
||||
vim.b[buf].snacks_gh = {
|
||||
repo = item.repo,
|
||||
type = item.type,
|
||||
number = item.number,
|
||||
}
|
||||
self:bo()
|
||||
self:wo()
|
||||
self:keys()
|
||||
M.attached[buf] = self
|
||||
vim.schedule(function()
|
||||
self:render()
|
||||
end)
|
||||
return self
|
||||
end
|
||||
|
||||
function M:update()
|
||||
if not self:valid() then
|
||||
return
|
||||
end
|
||||
self:render({ force = true })
|
||||
end
|
||||
|
||||
function M:keys()
|
||||
local actions = Actions.get_actions(self.item)
|
||||
|
||||
---@param name string
|
||||
local function wrap(name)
|
||||
local action = actions[name]
|
||||
if not action then
|
||||
return
|
||||
end
|
||||
---@type snacks.gh.Keymap.fn
|
||||
return function(item)
|
||||
action.action(item, { items = { item } })
|
||||
end
|
||||
end
|
||||
|
||||
for name, km in pairs(self.opts.keys or {}) do
|
||||
if km ~= false then
|
||||
local rhs = km[2]
|
||||
local desc = km.desc
|
||||
local action = type(rhs) == "function" and rhs or type(rhs) == "string" and wrap(rhs) or nil
|
||||
if action then
|
||||
Snacks.keymap.set(km.mode or "n", km[1], function()
|
||||
action(self.item, self)
|
||||
end, { buffer = self.buf, desc = desc })
|
||||
elseif type(rhs) == "string" and not Actions.actions[rhs] then
|
||||
Snacks.notify.error(("Invalid gh buffer keymap action `%s:%s`"):format(name, rhs))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M:valid()
|
||||
return self.buf and M.attached[self.buf] == self and vim.api.nvim_buf_is_valid(self.buf)
|
||||
end
|
||||
|
||||
---@param opts? {force?:boolean}
|
||||
function M:render(opts)
|
||||
if not self:valid() then
|
||||
return
|
||||
end
|
||||
opts = opts or {}
|
||||
self.item = Api.get(self.item)
|
||||
|
||||
self:bo()
|
||||
self:wo()
|
||||
|
||||
local spinner ---@type snacks.util.Spinner?
|
||||
local proc = Api.view(self.item, function(it, updated)
|
||||
vim.schedule(function()
|
||||
if not self:valid() then
|
||||
return
|
||||
end
|
||||
if spinner then
|
||||
spinner:stop()
|
||||
end
|
||||
self.item = it
|
||||
if updated then
|
||||
Render.render(self.buf, it, self.opts)
|
||||
self:keys()
|
||||
end
|
||||
end)
|
||||
end, { force = opts.force })
|
||||
|
||||
-- initial render (is partial if proc is running)
|
||||
if Item.is(self.item) then
|
||||
Render.render(self.buf, self.item, Snacks.config.merge({}, vim.deepcopy(self.opts), { partial = proc ~= nil }))
|
||||
end
|
||||
|
||||
if proc then
|
||||
spinner = Snacks.picker.util.spinner(self.buf)
|
||||
end
|
||||
end
|
||||
|
||||
function M:bo()
|
||||
vim.b[self.buf].snacks_statuscolumn_left = false
|
||||
Snacks.util.bo(self.buf, self.opts.bo)
|
||||
end
|
||||
|
||||
function M:wo()
|
||||
for _, win in ipairs(vim.fn.win_findbuf(self.buf)) do
|
||||
Snacks.util.wo(win, self.opts.wo)
|
||||
end
|
||||
end
|
||||
|
||||
---@param buf number
|
||||
---@param item? snacks.gh.api.View
|
||||
function M.attach(buf, item)
|
||||
M.setup()
|
||||
local ret = M.attached[buf]
|
||||
if ret then
|
||||
ret:update()
|
||||
return ret
|
||||
end
|
||||
if not item then
|
||||
local name = vim.api.nvim_buf_get_name(buf)
|
||||
local repo, type, number = name:match("^gh://([^/]+/[^/]+)/([^/]+)/(%d+)$")
|
||||
if not repo then
|
||||
Snacks.notify.error("Invalid gh:// buffer: " .. name)
|
||||
return
|
||||
end
|
||||
item = {
|
||||
repo = repo,
|
||||
type = type,
|
||||
number = number,
|
||||
}
|
||||
end
|
||||
return M.new(buf, item)
|
||||
end
|
||||
|
||||
--@param buf number
|
||||
function M.detach(buf)
|
||||
if not M.attached[buf] then
|
||||
return
|
||||
end
|
||||
M.attached[buf] = nil
|
||||
end
|
||||
|
||||
function M.setup()
|
||||
if did_setup then
|
||||
return
|
||||
end
|
||||
did_setup = true
|
||||
local group = vim.api.nvim_create_augroup("snacks.gh.buf", { clear = true })
|
||||
|
||||
vim.api.nvim_create_autocmd("BufReadCmd", {
|
||||
pattern = "gh://*",
|
||||
group = group,
|
||||
callback = function(e)
|
||||
vim.schedule(function()
|
||||
-- schedule since Neovim otherwise runs this in the autocmd window
|
||||
M.attach(e.buf)
|
||||
end)
|
||||
end,
|
||||
})
|
||||
|
||||
-- prevent altering the original image file
|
||||
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
||||
pattern = "gh://*",
|
||||
group = group,
|
||||
callback = function(e)
|
||||
vim.bo[e.buf].modified = false
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd("BufWinEnter", {
|
||||
pattern = "gh://*",
|
||||
group = group,
|
||||
callback = function(e)
|
||||
local buf = M.attached[e.buf]
|
||||
if buf then
|
||||
buf:bo()
|
||||
buf:wo()
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd("ColorScheme", {
|
||||
group = group,
|
||||
callback = function(e)
|
||||
for _, buf in pairs(M.attached) do
|
||||
buf:render()
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
-- detach on buffer delete
|
||||
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
|
||||
pattern = "gh://*",
|
||||
group = group,
|
||||
callback = function(ev)
|
||||
M.detach(ev.buf)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Keep some empty windows in sessions
|
||||
vim.api.nvim_create_autocmd("ExitPre", {
|
||||
group = group,
|
||||
callback = function()
|
||||
local keep = { "markdown.gh" }
|
||||
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
||||
local buf = vim.api.nvim_win_get_buf(win)
|
||||
if vim.tbl_contains(keep, vim.bo[buf].filetype) then
|
||||
vim.bo[buf].buftype = "" -- set buftype to empty to keep the window
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
171
lua/snacks/gh/init.lua
Normal file
171
lua/snacks/gh/init.lua
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
---@class snacks.gh
|
||||
---@field api snacks.gh.api
|
||||
---@field item snacks.picker.gh.Item
|
||||
local M = setmetatable({}, {
|
||||
---@param M snacks.gh
|
||||
__index = function(M, k)
|
||||
if vim.tbl_contains({ "api" }, k) then
|
||||
M[k] = require("snacks.gh." .. k)
|
||||
end
|
||||
return rawget(M, k)
|
||||
end,
|
||||
})
|
||||
|
||||
M.meta = {
|
||||
desc = "GitHub CLI integration",
|
||||
needs_setup = true,
|
||||
}
|
||||
|
||||
---@alias snacks.gh.Keymap.fn fun(item:snacks.picker.gh.Item, buf:snacks.gh.Buf)
|
||||
---@class snacks.gh.Keymap: vim.keymap.set.Opts
|
||||
---@field [1] string lhs
|
||||
---@field [2] string|snacks.gh.Keymap.fn rhs
|
||||
---@field mode? string|string[] defaults to `n`
|
||||
|
||||
---@class snacks.gh.Config
|
||||
local defaults = {
|
||||
--- Keymaps for GitHub buffers
|
||||
---@type table<string, snacks.gh.Keymap|false>?
|
||||
-- stylua: ignore
|
||||
keys = {
|
||||
select = { "<cr>", "gh_actions", desc = "Select Action" },
|
||||
edit = { "i" , "gh_edit" , desc = "Edit" },
|
||||
comment = { "a" , "gh_comment", desc = "Add Comment" },
|
||||
close = { "c" , "gh_close" , desc = "Close" },
|
||||
reopen = { "o" , "gh_reopen" , desc = "Reopen" },
|
||||
},
|
||||
---@type vim.wo|{}
|
||||
wo = {
|
||||
breakindent = true,
|
||||
wrap = true,
|
||||
showbreak = "",
|
||||
linebreak = true,
|
||||
number = false,
|
||||
relativenumber = false,
|
||||
foldexpr = "v:lua.vim.treesitter.foldexpr()",
|
||||
foldmethod = "expr",
|
||||
concealcursor = "n",
|
||||
conceallevel = 2,
|
||||
winhighlight = Snacks.util.winhl({
|
||||
Normal = "SnacksGhNormal",
|
||||
NormalFloat = "SnacksGhNormalFloat",
|
||||
FloatBorder = "SnacksGhBorder",
|
||||
FloatTitle = "SnacksGhTitle",
|
||||
FloatFooter = "SnacksGhFooter",
|
||||
}),
|
||||
},
|
||||
---@type vim.bo|{}
|
||||
bo = {},
|
||||
-- stylua: ignore
|
||||
icons = {
|
||||
logo = " ",
|
||||
user= " ",
|
||||
checkmark = " ",
|
||||
crossmark = " ",
|
||||
block = "■",
|
||||
file = " ",
|
||||
checks = {
|
||||
pending = " ",
|
||||
success = " ",
|
||||
failure = "",
|
||||
skipped = " ",
|
||||
},
|
||||
issue = {
|
||||
open = " ",
|
||||
completed = " ",
|
||||
other = " "
|
||||
},
|
||||
pr = {
|
||||
open = " ",
|
||||
closed = " ",
|
||||
merged = " ",
|
||||
draft = " ",
|
||||
other = " ",
|
||||
},
|
||||
merge_status = {
|
||||
clean = " ",
|
||||
dirty = " ",
|
||||
blocked = " ",
|
||||
unstable = " "
|
||||
},
|
||||
reactions = {
|
||||
thumbs_up = "👍",
|
||||
thumbs_down = "👎",
|
||||
eyes = "👀",
|
||||
confused = "😕",
|
||||
heart = "❤️",
|
||||
hooray = "🎉",
|
||||
laugh = "😄",
|
||||
rocket = "🚀",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Snacks.util.set_hl({
|
||||
Normal = "NormalFloat",
|
||||
NormalFloat = "NormalFloat",
|
||||
Border = "FloatBorder",
|
||||
Title = "FloatTitle",
|
||||
Footer = "FloatFooter",
|
||||
Number = "Number",
|
||||
Green = { fg = "#28a745" },
|
||||
Purple = { fg = "#6f42c1" },
|
||||
Gray = { fg = "#6a737d" },
|
||||
Red = { fg = "#d73a49" },
|
||||
Branch = "@markup.link",
|
||||
IssueOpen = "SnacksGhGreen",
|
||||
IssueCompleted = "SnacksGhPurple",
|
||||
IssueOther = "SnacksGhGray",
|
||||
PrOpen = "SnacksGhGreen",
|
||||
PrClosed = "SnacksGhRed",
|
||||
PrMerged = "SnacksGhPurple",
|
||||
PrDraft = "SnacksGhGray",
|
||||
Label = "@property",
|
||||
Delim = "@punctuation.delimiter",
|
||||
UserBadge = "DiagnosticInfo",
|
||||
AuthorBadge = "DiagnosticWarn",
|
||||
OwnerBadge = "DiagnosticError",
|
||||
BotBadge = { fg = Snacks.util.color({ "NonText", "SignColumn", "FoldColumn" }) },
|
||||
ReactionBadge = "Special",
|
||||
AssocBadge = {}, -- will be set to inverse of Normal
|
||||
StatBadge = "Special",
|
||||
PrClean = "DiagnosticInfo",
|
||||
PrUnstable = "DiagnosticWarn",
|
||||
PrDirty = "DiagnosticError",
|
||||
PrBlocked = "DiagnosticError",
|
||||
Additions = "SnacksGhGreen",
|
||||
Deletions = "SnacksGhRed",
|
||||
CheckPending = "DiagnosticWarn",
|
||||
CheckSuccess = "SnacksGhGreen",
|
||||
CheckFailure = "SnacksGhRed",
|
||||
CheckSkipped = "SnacksGhStat",
|
||||
Stat = { fg = Snacks.util.color("SignColumn") },
|
||||
}, { default = true, prefix = "SnacksGh" })
|
||||
|
||||
M._config = nil ---@type snacks.gh.Config?
|
||||
local did_setup = false
|
||||
|
||||
function M.config()
|
||||
M._config = M._config or Snacks.config.get("gh", defaults)
|
||||
return M._config
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param ev? vim.api.keyset.create_autocmd.callback_args
|
||||
function M.setup(ev)
|
||||
if did_setup then
|
||||
return
|
||||
end
|
||||
did_setup = true
|
||||
|
||||
-- vim.treesitter.language.register("markdown", "gh")
|
||||
|
||||
require("snacks.gh.buf").setup()
|
||||
if ev then
|
||||
vim.schedule(function()
|
||||
require("snacks.gh.buf").attach(ev.buf)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
129
lua/snacks/gh/item.lua
Normal file
129
lua/snacks/gh/item.lua
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
---@class snacks.picker.gh.Item
|
||||
---@field opts snacks.gh.api.Config
|
||||
local M = {}
|
||||
|
||||
---@param s? string
|
||||
local function ts(s)
|
||||
return (s and vim.fn.strptime("%Y-%m-%dT%H:%M:%SZ", s)) or nil
|
||||
end
|
||||
|
||||
local time_fields = { created = "createdAt", updated = "updatedAt", closed = "closedAt", merged = "mergedAt" }
|
||||
|
||||
---@param item snacks.gh.Item
|
||||
---@param opts snacks.gh.api.Config
|
||||
function M.new(item, opts)
|
||||
if getmetatable(item) == M then
|
||||
return item --[[@as snacks.picker.gh.Item]]
|
||||
end
|
||||
local self = setmetatable({}, M) --[[@as snacks.picker.gh.Item]]
|
||||
for k, v in pairs(item) do
|
||||
if v == vim.NIL then
|
||||
item[k] = nil
|
||||
end
|
||||
end
|
||||
self.item = item
|
||||
self.opts = opts
|
||||
self.type = opts.type
|
||||
self.repo = opts.repo
|
||||
self.fields = {}
|
||||
for _, field in ipairs(opts.fields or {}) do
|
||||
self.fields[field] = true
|
||||
end
|
||||
self:update()
|
||||
return self --[[@as snacks.picker.gh.Item]]
|
||||
end
|
||||
|
||||
---@param item any
|
||||
function M.is(item)
|
||||
return getmetatable(item) == M
|
||||
end
|
||||
|
||||
function M:__index(key)
|
||||
if time_fields[key] then
|
||||
return ts(self.item[time_fields[key]])
|
||||
end
|
||||
return rawget(M, key) or rawget(self.item, key)
|
||||
end
|
||||
|
||||
---@param fields string[]
|
||||
function M:need(fields)
|
||||
---@param field string
|
||||
return vim.tbl_filter(function(field)
|
||||
return not self.fields[field]
|
||||
end, fields)
|
||||
end
|
||||
|
||||
---@param data? table<string, any>
|
||||
---@param fields? string[]
|
||||
function M:update(data, fields)
|
||||
for k, v in pairs(data or {}) do
|
||||
---@diagnostic disable-next-line: no-unknown
|
||||
self.item[k] = v ~= vim.NIL and v or nil
|
||||
end
|
||||
local item = self.item
|
||||
for _, field in ipairs(fields or {}) do
|
||||
if data and data[field] == nil then
|
||||
self.item[field] = nil
|
||||
end
|
||||
self.fields[field] = true
|
||||
end
|
||||
if not self.repo and item.url then
|
||||
local repo = item.url:match("github%.com/([^/]+/[^/]+)/")
|
||||
if repo then
|
||||
self.repo = repo
|
||||
end
|
||||
end
|
||||
if self.repo then
|
||||
self.uri = ("gh://%s/%s/%s"):format(self.repo, self.type, tostring(item.number or ""))
|
||||
self.file = self.uri
|
||||
end
|
||||
self.author = item.author and item.author.login or nil
|
||||
self.hash = item.number and ("#" .. tostring(item.number)) or nil
|
||||
self.state = item.state and item.state:lower() or nil
|
||||
self.status = self.state
|
||||
self.state_reason = item.stateReason and item.stateReason:lower() or nil
|
||||
self.draft = item.isDraft
|
||||
self.label = item.labels
|
||||
and table.concat(
|
||||
---@param label snacks.gh.Label
|
||||
vim.tbl_map(function(label)
|
||||
return label.name
|
||||
end, item.labels),
|
||||
","
|
||||
)
|
||||
or nil
|
||||
self.body = item.body and item.body:gsub("\r\n", "\n") or nil
|
||||
for _, comment in ipairs(item.comments or {}) do
|
||||
comment.body = comment.body and comment.body:gsub("\r\n", "\n") or nil
|
||||
setmetatable(comment, {
|
||||
__index = function(tbl, key)
|
||||
if time_fields[key] then
|
||||
return ts(tbl[time_fields[key]])
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
if item.reactionGroups then
|
||||
self.reactions = {}
|
||||
for _, reaction in ipairs(item.reactionGroups) do
|
||||
table.insert(
|
||||
self.reactions,
|
||||
{ content = reaction.content:lower(), count = reaction.users and reaction.users.totalCount or 0 }
|
||||
)
|
||||
end
|
||||
end
|
||||
if self.opts.transform then
|
||||
self.opts.transform(self)
|
||||
end
|
||||
self.text = Snacks.picker.util.text(self.item, self.opts.text or self.opts.fields or {})
|
||||
end
|
||||
|
||||
---@param item snacks.gh.api.View
|
||||
function M.to_uri(item)
|
||||
if item.uri then
|
||||
return item.uri
|
||||
end
|
||||
return ("gh://%s/%s/%s"):format(item.repo or "", assert(item.type), tostring(assert(item.number)))
|
||||
end
|
||||
|
||||
return M
|
||||
378
lua/snacks/gh/render.lua
Normal file
378
lua/snacks/gh/render.lua
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
local Markdown = require("snacks.picker.util.markdown")
|
||||
|
||||
local M = {}
|
||||
local extend = Snacks.picker.highlight.extend
|
||||
|
||||
---@param field string
|
||||
local function time_prop(field)
|
||||
return {
|
||||
name = Snacks.picker.util.title(field),
|
||||
hl = function(item)
|
||||
if not item[field] then
|
||||
return
|
||||
end
|
||||
return { { Snacks.picker.util.reltime(item[field]), "SnacksPickerGitDate" } }
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
---@type {name: string, hl:fun(item:snacks.picker.gh.Item, opts:snacks.gh.Config):snacks.picker.Highlight[]? }[]
|
||||
M.props = {
|
||||
{
|
||||
name = "Status",
|
||||
hl = function(item, opts)
|
||||
-- Status Icon
|
||||
local icons = opts.icons[item.type]
|
||||
local status = icons[item.status] and item.status or "other"
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
if status then
|
||||
local icon = icons[status]
|
||||
local hl = "SnacksGh" .. Snacks.picker.util.title(item.type) .. Snacks.picker.util.title(status)
|
||||
local text = icon .. Snacks.picker.util.title(item.status or "other")
|
||||
extend(ret, Snacks.picker.highlight.badge(text, { bg = Snacks.util.color(hl), fg = "#ffffff" }))
|
||||
end
|
||||
if item.baseRefName and item.headRefName then
|
||||
ret[#ret + 1] = { " " }
|
||||
vim.list_extend(ret, {
|
||||
{ item.baseRefName, "SnacksGhBranch" },
|
||||
{ " ← ", "SnacksGhDelim" },
|
||||
{ item.headRefName, "SnacksGhBranch" },
|
||||
})
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Repo",
|
||||
hl = function(item, opts)
|
||||
return { { opts.icons.logo, "Special" }, { item.repo, "@markup.link" } }
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Author",
|
||||
hl = function(item, opts)
|
||||
return Snacks.picker.highlight.badge(opts.icons.user .. " " .. item.author, "SnacksGhUserBadge")
|
||||
end,
|
||||
},
|
||||
time_prop("created"),
|
||||
time_prop("updated"),
|
||||
time_prop("closed"),
|
||||
time_prop("merged"),
|
||||
{
|
||||
name = "Reactions",
|
||||
hl = function(item, opts)
|
||||
if item.reactions then
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
table.sort(item.reactions, function(a, b)
|
||||
return a.count > b.count
|
||||
end)
|
||||
for _, r in pairs(item.reactions) do
|
||||
local badge = Snacks.picker.highlight.badge(
|
||||
opts.icons.reactions[r.content] .. " " .. tostring(r.count),
|
||||
"SnacksGhReactionBadge"
|
||||
)
|
||||
vim.list_extend(ret, badge)
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
return ret
|
||||
end
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Labels",
|
||||
hl = function(item)
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
for _, label in ipairs(item.item.labels or {}) do
|
||||
local color = label.color or "888888"
|
||||
local badge = Snacks.picker.highlight.badge(label.name, "#" .. color)
|
||||
vim.list_extend(ret, badge)
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Assignees",
|
||||
hl = function(item)
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
for _, u in ipairs(item.item.assignees or {}) do
|
||||
local badge = Snacks.picker.highlight.badge(u.login, "Identifier")
|
||||
vim.list_extend(ret, badge)
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Milestone",
|
||||
hl = function(item)
|
||||
if item.item.milestone then
|
||||
return Snacks.picker.highlight.badge(item.item.milestone.title, "Title")
|
||||
end
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Merge Status",
|
||||
hl = function(item, opts)
|
||||
if not item.mergeStateStatus or item.state ~= "open" then
|
||||
return
|
||||
end
|
||||
local status = item.mergeStateStatus:lower()
|
||||
status = opts.icons.merge_status[status] and status or "dirty"
|
||||
local icon = opts.icons.merge_status[status]
|
||||
status = Snacks.picker.util.title(status)
|
||||
local hl = "SnacksGhPr" .. status
|
||||
return { { icon .. " " .. status, hl } }
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Checks",
|
||||
hl = function(item, opts)
|
||||
if item.type ~= "pr" then
|
||||
return
|
||||
end
|
||||
if #(item.statusCheckRollup or {}) == 0 then
|
||||
return { { " " } }
|
||||
end
|
||||
local workflows = {} ---@type table<string, string>
|
||||
for _, check in ipairs(item.statusCheckRollup or {}) do
|
||||
local status = check.status == "COMPLETED" and check.conclusion or "pending"
|
||||
status = Snacks.picker.util.title(status:lower())
|
||||
workflows[check.workflowName .. ":" .. check.name] = status
|
||||
end
|
||||
local stats = {} ---@type table<string, number>
|
||||
for _, status in pairs(workflows) do
|
||||
stats[status] = (stats[status] or 0) + 1
|
||||
end
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
local order = { "Success", "Failure", "Pending", "Skipped" }
|
||||
for _, status in ipairs(order) do
|
||||
local count = stats[status]
|
||||
if count then
|
||||
local icon = opts.icons.checks[status:lower()] or opts.icons.checks["pending"]
|
||||
local badge = Snacks.picker.highlight.badge(icon .. " " .. tostring(count), "SnacksGhCheck" .. status)
|
||||
vim.list_extend(ret, badge)
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
end
|
||||
ret[#ret + 1] = { " " }
|
||||
for _, status in ipairs(order) do
|
||||
local count = stats[status]
|
||||
if count then
|
||||
ret[#ret + 1] = { string.rep(opts.icons.block, count), "SnacksGHCheck" .. status }
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Mergeable",
|
||||
hl = function(item, opts)
|
||||
if not item.mergeable then
|
||||
return
|
||||
end
|
||||
return {
|
||||
{
|
||||
(item.mergeable and opts.icons.checkmark or opts.icons.crossmark),
|
||||
item.mergeable and "SnacksGhPrClean" or "SnacksGhPrDirty",
|
||||
},
|
||||
} or nil
|
||||
end,
|
||||
},
|
||||
{
|
||||
name = "Changes",
|
||||
hl = function(item, opts)
|
||||
if item.type ~= "pr" then
|
||||
return
|
||||
end
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
|
||||
if item.changedFiles then
|
||||
ret = Snacks.picker.highlight.badge(opts.icons.file .. item.changedFiles, "SnacksGhStatBadge")
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
|
||||
if (item.additions or 0) > 0 then
|
||||
ret[#ret + 1] = { "+" .. tostring(item.additions), "SnacksGhAdditions" }
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
if (item.deletions or 0) > 0 then
|
||||
ret[#ret + 1] = { "-" .. tostring(item.deletions), "SnacksGhDeletions" }
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
if #ret == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
if item.additions and item.deletions then
|
||||
local unit = math.ceil((item.additions + item.deletions) / 5)
|
||||
local additions = math.floor((0.5 + item.additions) / unit)
|
||||
local deletions = math.floor((0.5 + item.deletions) / unit)
|
||||
local neutral = 5 - additions - deletions
|
||||
|
||||
ret[#ret + 1] = { string.rep(opts.icons.block, additions), "SnacksGHAdditions" }
|
||||
ret[#ret + 1] = { string.rep(opts.icons.block, deletions), "SnacksGHDeletions" }
|
||||
ret[#ret + 1] = { string.rep(opts.icons.block, neutral), "SnacksGHStat" }
|
||||
end
|
||||
|
||||
return ret
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
local ns = vim.api.nvim_create_namespace("snacks.gh.render")
|
||||
|
||||
---@param buf number
|
||||
---@param item snacks.picker.gh.Item
|
||||
---@param opts snacks.gh.Config|{partial?:boolean}
|
||||
function M.render(buf, item, opts)
|
||||
if not vim.api.nvim_buf_is_valid(buf) then
|
||||
return
|
||||
end
|
||||
|
||||
local lines = {} ---@type snacks.picker.Highlight[][]
|
||||
|
||||
item.msg = item.title
|
||||
---@diagnostic disable-next-line: missing-fields
|
||||
lines[#lines + 1] = Snacks.picker.format.commit_message(item, {})
|
||||
vim.list_extend(lines[#lines], { { " " }, { item.hash, "SnacksPickerDimmed" } }) -- space after title
|
||||
lines[#lines + 1] = {} -- empty line
|
||||
|
||||
for _, prop in ipairs(M.props) do
|
||||
local value = prop.hl(item, opts)
|
||||
if value and #value > 0 then
|
||||
local line = {} ---@type snacks.picker.Highlight[]
|
||||
line[#line + 1] = { prop.name, "SnacksGhLabel" }
|
||||
line[#line + 1] = { ":", "SnacksGhDelim" }
|
||||
line[#line + 1] = { " " }
|
||||
extend(line, value)
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
end
|
||||
|
||||
lines[#lines + 1] = {} -- empty line
|
||||
lines[#lines + 1] = { { "---", "@punctuation.special.markdown" } }
|
||||
lines[#lines + 1] = {} -- empty line
|
||||
|
||||
do
|
||||
local text = item.body or ""
|
||||
text = text:gsub("<%!%-%-.-%-%->%s*", "") -- remove html comments
|
||||
local body = vim.split(text or "", "\n")
|
||||
while #body > 0 and body[1]:match("^%s*$") do
|
||||
table.remove(body, 1)
|
||||
end
|
||||
for _, l in ipairs(body) do
|
||||
lines[#lines + 1] = { { l } }
|
||||
end
|
||||
end
|
||||
local comments = item.comments or {}
|
||||
|
||||
if #comments > 0 then
|
||||
lines[#lines + 1] = {} -- empty line
|
||||
lines[#lines + 1] = { { "---", "@punctuation.special.markdown" } }
|
||||
|
||||
for _, comment in ipairs(comments) do
|
||||
lines[#lines + 1] = {} -- empty line
|
||||
local ch = {} ---@type snacks.picker.Highlight[]
|
||||
local is_bot = comment.author.login == "github-actions"
|
||||
if is_bot then
|
||||
extend(ch, Snacks.picker.highlight.badge(opts.icons.logo .. " " .. comment.author.login, "SnacksGhBotBadge"))
|
||||
else
|
||||
extend(ch, Snacks.picker.highlight.badge(opts.icons.user .. " " .. comment.author.login, "SnacksGhUserBadge"))
|
||||
end
|
||||
ch[#ch + 1] = { " on " .. Snacks.picker.util.reltime(comment.created), "SnacksPickerGitDate" }
|
||||
local assoc = comment.authorAssociation
|
||||
assoc = assoc and assoc ~= "NONE" and Snacks.picker.util.title(assoc:lower()) or nil
|
||||
assoc = comment.author.login == item.author and "Author" or assoc
|
||||
if assoc then
|
||||
ch[#ch + 1] = { " " }
|
||||
extend(
|
||||
ch,
|
||||
Snacks.picker.highlight.badge(
|
||||
assoc,
|
||||
assoc == "Author" and "SnacksGhAuthorBadge"
|
||||
or assoc == "Owner" and "SnacksGhOwnerBadge"
|
||||
or "SnacksGhAssocBadge"
|
||||
)
|
||||
)
|
||||
end
|
||||
for _, r in ipairs(comment.reactionGroups or {}) do
|
||||
ch[#ch + 1] = { " " }
|
||||
local badge = Snacks.picker.highlight.badge(
|
||||
opts.icons.reactions[r.content:lower()] .. " " .. tostring(r.users.totalCount),
|
||||
"SnacksGhReactionBadge"
|
||||
)
|
||||
extend(ch, badge)
|
||||
end
|
||||
lines[#lines + 1] = ch
|
||||
|
||||
local body = vim.split(comment.body or "", "\n")
|
||||
for _, l in ipairs(body) do
|
||||
lines[#lines + 1] = {
|
||||
{
|
||||
col = 0,
|
||||
virt_text = { { "┃", "@punctuation.definition.blockquote.markdown" } },
|
||||
virt_text_pos = "overlay",
|
||||
virt_text_win_col = 1,
|
||||
hl_mode = "combine",
|
||||
virt_text_repeat_linebreak = true,
|
||||
},
|
||||
{ " " },
|
||||
{ l },
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local buf_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||
|
||||
vim.bo[buf].modifiable = true
|
||||
vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1)
|
||||
|
||||
local changed = #lines ~= #buf_lines
|
||||
for l, line in ipairs(lines) do
|
||||
local line_text, extmarks = Snacks.picker.highlight.to_text(line)
|
||||
if line_text ~= buf_lines[l] then
|
||||
vim.api.nvim_buf_set_lines(buf, l - 1, l, false, { line_text })
|
||||
changed = true
|
||||
end
|
||||
for _, extmark in ipairs(extmarks) do
|
||||
local e = vim.deepcopy(extmark)
|
||||
e.col = nil
|
||||
e.row = nil
|
||||
e.field = nil
|
||||
local ok, err = pcall(vim.api.nvim_buf_set_extmark, buf, ns, l - 1, extmark.col, e)
|
||||
if not ok then
|
||||
Snacks.notify.error(
|
||||
"Failed to set extmark. This should not happen. Please report.\n"
|
||||
.. err
|
||||
.. "\n```lua\n"
|
||||
.. vim.inspect(extmark)
|
||||
.. "\n```"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
if #lines < #buf_lines then
|
||||
vim.api.nvim_buf_set_lines(buf, #lines, -1, false, {})
|
||||
end
|
||||
|
||||
if changed then
|
||||
Markdown.render(buf)
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
for _, win in ipairs(vim.fn.win_findbuf(buf)) do
|
||||
vim.api.nvim_win_call(win, function()
|
||||
if vim.wo.foldmethod == "expr" then
|
||||
vim.wo.foldmethod = "expr"
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
vim.bo[buf].modified = false
|
||||
vim.bo[buf].modifiable = false
|
||||
end
|
||||
|
||||
return M
|
||||
148
lua/snacks/gh/types.lua
Normal file
148
lua/snacks/gh/types.lua
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
---@class snacks.gh.api.Config
|
||||
---@field type "issue" | "pr"
|
||||
---@field repo? string
|
||||
---@field fields string[]
|
||||
---@field view string[] -- fields to fetch for gh view
|
||||
---@field list string[] -- fields to fetch for gh list
|
||||
---@field text string[]
|
||||
---@field options string[]
|
||||
---@field transform? fun(item: snacks.picker.gh.Item): snacks.picker.gh.Item?
|
||||
|
||||
---@class snacks.picker.gh.list.Config: snacks.picker.gh.Config
|
||||
---@field type "issue" | "pr"
|
||||
|
||||
---@class snacks.picker.gh.api.Config: snacks.picker.gh.Config
|
||||
---@field api snacks.gh.api.Api
|
||||
---@field transform? fun(item: snacks.picker.finder.Item): snacks.picker.finder.Item?
|
||||
|
||||
---@alias snacks.gh.api.View snacks.picker.gh.Item|{number: number, type: string, repo: string}
|
||||
|
||||
---@class snacks.gh.api.Cmd
|
||||
---@field args string[]
|
||||
---@field repo? string
|
||||
---@field input? string
|
||||
---@field on_error? fun(proc: snacks.spawn.Proc, err: string)
|
||||
|
||||
---@class snacks.gh.api.Api
|
||||
---@field endpoint string
|
||||
---@field cache? string cache the response, e.g. "3600s", "1h"
|
||||
---@field fields? table<string, string|number|boolean>
|
||||
---@field header? table<string, string|number|boolean>
|
||||
---@field jq? string
|
||||
---@field input? string
|
||||
---@field method? "GET" | "POST" | "PATCH" | "PUT" | "DELETE"
|
||||
---@field paginate? boolean
|
||||
---@field silent? boolean
|
||||
---@field slurp? boolean
|
||||
---@field on_error? fun(proc: snacks.spawn.Proc, err: string)
|
||||
|
||||
---@alias snacks.gh.Field {arg:string, prop:string, name:string}
|
||||
|
||||
---@class snacks.gh.cli.Action: snacks.gh.api.Cmd
|
||||
---@field args? string[]
|
||||
---@field stdin? boolean -- whether to write to stdin
|
||||
---@field edit? string field to edit
|
||||
---@field api? snacks.gh.api.Api -- api options
|
||||
---@field cmd? string -- subcommand to run (e.g., "issue edit" or "pr comment")
|
||||
---@field fields? snacks.gh.Field[] -- field args to parse from the body
|
||||
---@field title? string -- title of the scratch buffer
|
||||
---@field template? string -- template to use for the scratch buffer
|
||||
---@field desc? string -- description to show in the scratch buffer
|
||||
---@field icon? string -- icon to show in the scratch buffer
|
||||
---@field type? "issue" | "pr" -- action for items of this type (nil means both)
|
||||
---@field enabled? fun(item: snacks.picker.gh.Item): boolean -- whether the action is enabled for the item
|
||||
---@field success? string -- success message to show after the action
|
||||
---@field confirm? string -- confirmation message to show before performing the action
|
||||
---@field refresh? boolean -- whether to refresh the item after performing the action (default: true)
|
||||
|
||||
---@class snacks.gh.api.Fetch: snacks.gh.api.Cmd
|
||||
---@field fields string[]
|
||||
|
||||
---@alias snacks.gh.Reaction { content: string, users: { totalCount: number } }
|
||||
|
||||
---@class snacks.gh.Label
|
||||
---@field id string
|
||||
---@field name string
|
||||
---@field color string
|
||||
---@field description? string
|
||||
|
||||
---@class snacks.gh.User
|
||||
---@field id string
|
||||
---@field login string
|
||||
---@field name string
|
||||
---@field is_bot? boolean
|
||||
|
||||
---@class snacks.gh.Check
|
||||
---@field __typename string
|
||||
---@field completedAt? string
|
||||
---@field conclusion? "SUCCESS" | "FAILURE" | "SKIPPED"
|
||||
---@field detailsUrl? string
|
||||
---@field name string
|
||||
---@field startedAt? string
|
||||
---@field status "PENDING" | "COMPLETED"
|
||||
---@field workflowName string
|
||||
|
||||
---@class snacks.gh.Item
|
||||
---@field number number
|
||||
---@field id string
|
||||
---@field title string
|
||||
---@field labels? snacks.gh.Label[]
|
||||
---@field author? snacks.gh.User
|
||||
---@field state string
|
||||
---@field stateReason? string
|
||||
---@field updatedAt string
|
||||
---@field url string
|
||||
---@field reactionGroups? snacks.gh.Reaction[]
|
||||
---@field body? string
|
||||
---@field comments? snacks.gh.Comment[]
|
||||
---@field changedFiles? number
|
||||
---@field additions? number
|
||||
---@field deletions? number
|
||||
---@field mergeStateStatus? string
|
||||
---@field mergeable? boolean
|
||||
---@field commits? snacks.gh.Commit[]
|
||||
---@field statusCheckRollup? snacks.gh.Check[]
|
||||
---@field baseRefName? string
|
||||
---@field headRefName? string
|
||||
---@field isDraft? boolean
|
||||
|
||||
---@class snacks.gh.Commit
|
||||
---@field oid string
|
||||
---@field messageHeadline string
|
||||
---@field messageBody? string
|
||||
---@field committedDate string
|
||||
---@field authors? snacks.gh.User[]
|
||||
---@field authoredDate string
|
||||
|
||||
---@class snacks.gh.Comment
|
||||
---@field id string
|
||||
---@field url string
|
||||
---@field author { login: string }
|
||||
---@field authorAssociation? string
|
||||
---@field includesCreatedEdit boolean
|
||||
---@field viewerDidAuthor boolean
|
||||
---@field isMinimized boolean
|
||||
---@field minimizedReason string
|
||||
---@field body string
|
||||
---@field createdAt string
|
||||
---@field reactionGroups? snacks.gh.Reaction[]
|
||||
|
||||
---@class snacks.picker.gh.Item: snacks.picker.Item,snacks.gh.Item,snacks.picker.finder.Item
|
||||
---@field type "issue" | "pr"
|
||||
---@field dirty? boolean
|
||||
---@field uri string
|
||||
---@field repo? string
|
||||
---@field hash string
|
||||
---@field status string
|
||||
---@field author? string
|
||||
---@field label? string
|
||||
---@field status_reason? string
|
||||
---@field item snacks.gh.Item
|
||||
---@field body? string
|
||||
---@field reactions? {content: string, count: number}[]
|
||||
---@field fields table<string, boolean>
|
||||
---@field created number
|
||||
---@field updated number
|
||||
---@field closed? number
|
||||
---@field merged? number
|
||||
---@field draft? boolean
|
||||
|
|
@ -113,7 +113,7 @@ function M:update()
|
|||
pos = i.pos,
|
||||
range = i.range,
|
||||
inline = true,
|
||||
conceal = conceal(i.lang, i.type),
|
||||
conceal = vim.b[self.buf].snacks_image_conceal or conceal(i.lang, i.type),
|
||||
type = i.type,
|
||||
---@param p snacks.image.Placement
|
||||
on_update = function(p)
|
||||
|
|
|
|||
|
|
@ -203,6 +203,15 @@ function M.setup(opts)
|
|||
})
|
||||
end
|
||||
|
||||
vim.api.nvim_create_autocmd("BufReadCmd", {
|
||||
once = true,
|
||||
pattern = "gh://*",
|
||||
group = group,
|
||||
callback = function(e)
|
||||
require("snacks.gh").setup(e)
|
||||
end,
|
||||
})
|
||||
|
||||
if M.config.statuscolumn.enabled then
|
||||
vim.o.statuscolumn = [[%!v:lua.require'snacks.statuscolumn'.get()]]
|
||||
end
|
||||
|
|
|
|||
|
|
@ -52,12 +52,7 @@ function M.new(opts)
|
|||
|
||||
local zindex = self.opts.layout.zindex or 50
|
||||
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
||||
if vim.w[win].snacks_layout then
|
||||
local winc = vim.api.nvim_win_get_config(win)
|
||||
if winc.zindex and winc.zindex >= zindex then
|
||||
zindex = winc.zindex + 1
|
||||
end
|
||||
end
|
||||
zindex = math.max(zindex, (vim.api.nvim_win_get_config(win).zindex or 0) + 1)
|
||||
end
|
||||
self.opts.layout.zindex = zindex + 2
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
---@field debug snacks.debug
|
||||
---@field dim snacks.dim
|
||||
---@field explorer snacks.explorer
|
||||
---@field gh snacks.gh
|
||||
---@field git snacks.git
|
||||
---@field gitbrowse snacks.gitbrowse
|
||||
---@field health snacks.health
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
---@field dashboard? snacks.dashboard.Config|{}
|
||||
---@field dim? snacks.dim.Config|{}
|
||||
---@field explorer? snacks.explorer.Config|{}
|
||||
---@field gh? snacks.gh.Config|{}
|
||||
---@field gitbrowse? snacks.gitbrowse.Config|{}
|
||||
---@field image? snacks.image.Config|{}
|
||||
---@field indent? snacks.indent.Config|{}
|
||||
|
|
|
|||
|
|
@ -175,8 +175,7 @@ end
|
|||
|
||||
function M.cancel(picker)
|
||||
picker:norm(function()
|
||||
local main = require("snacks.picker.core.main").new({ float = false, file = false })
|
||||
vim.api.nvim_set_current_win(main:get())
|
||||
picker.main = picker:filter().current_win
|
||||
picker:close()
|
||||
end)
|
||||
end
|
||||
|
|
@ -335,9 +334,7 @@ function M.bufdelete(picker)
|
|||
if non_buf_delete_requested then
|
||||
Snacks.notify.warn("Only open buffers can be deleted", { title = "Snacks Picker" })
|
||||
end
|
||||
picker.list:set_selected()
|
||||
picker.list:set_target()
|
||||
picker:find()
|
||||
picker:refresh()
|
||||
end
|
||||
|
||||
function M.git_stage(picker)
|
||||
|
|
@ -354,9 +351,7 @@ function M.git_stage(picker)
|
|||
Snacks.picker.util.cmd(cmd, function()
|
||||
done = done + 1
|
||||
if done == #items then
|
||||
picker.list:set_selected()
|
||||
picker.list:set_target()
|
||||
picker:find()
|
||||
picker:refresh()
|
||||
end
|
||||
end, opts)
|
||||
end
|
||||
|
|
@ -395,9 +390,7 @@ function M.git_restore(picker)
|
|||
done = done + 1
|
||||
if done == #items then
|
||||
vim.schedule(function()
|
||||
picker.list:set_selected()
|
||||
picker.list:set_target()
|
||||
picker:find()
|
||||
picker:refresh()
|
||||
vim.cmd.startinsert()
|
||||
vim.cmd.checktime()
|
||||
end)
|
||||
|
|
@ -481,9 +474,7 @@ function M.git_branch_del(picker, item)
|
|||
Snacks.picker.util.cmd({ "git", "branch", "-D", branch }, function(_, code)
|
||||
Snacks.notify("Deleted Branch `" .. branch .. "`", { title = "Snacks Picker" })
|
||||
vim.cmd.checktime()
|
||||
picker.list:set_selected()
|
||||
picker.list:set_target()
|
||||
picker:find()
|
||||
picker:refresh()
|
||||
end, { cwd = picker:cwd() })
|
||||
end)
|
||||
end, { cwd = picker:cwd() })
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ local defaults = {
|
|||
["gg"] = "list_top",
|
||||
["j"] = "list_down",
|
||||
["k"] = "list_up",
|
||||
["q"] = "close",
|
||||
["q"] = "cancel",
|
||||
},
|
||||
b = {
|
||||
minipairs_disable = true,
|
||||
|
|
@ -312,7 +312,7 @@ local defaults = {
|
|||
["i"] = "focus_input",
|
||||
["j"] = "list_down",
|
||||
["k"] = "list_up",
|
||||
["q"] = "close",
|
||||
["q"] = "cancel",
|
||||
["zb"] = "list_scroll_bottom",
|
||||
["zt"] = "list_scroll_top",
|
||||
["zz"] = "list_scroll_center",
|
||||
|
|
@ -326,7 +326,7 @@ local defaults = {
|
|||
preview = {
|
||||
keys = {
|
||||
["<Esc>"] = "cancel",
|
||||
["q"] = "close",
|
||||
["q"] = "cancel",
|
||||
["i"] = "focus_input",
|
||||
["<a-w>"] = "cycle_win",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ M.select = {
|
|||
backdrop = false,
|
||||
width = 0.5,
|
||||
min_width = 80,
|
||||
max_width = 100,
|
||||
height = 0.4,
|
||||
min_height = 3,
|
||||
box = "vertical",
|
||||
|
|
|
|||
|
|
@ -208,6 +208,107 @@ M.files = {
|
|||
supports_live = true,
|
||||
}
|
||||
|
||||
---@class snacks.picker.gh.Config: snacks.picker.Config
|
||||
---@field app? string GitHub App author
|
||||
---@field assignee? string filter by assignee
|
||||
---@field author? string filter by author
|
||||
---@field jq? string custom jq filter
|
||||
---@field label? string filter by label(s)
|
||||
---@field limit? number number of items to fetch (default: 50)
|
||||
---@field repo? string GitHub repository (owner/repo). Defaults to current git repo
|
||||
|
||||
---@class snacks.picker.gh.issue.Config: snacks.picker.gh.Config
|
||||
---@field state "open" | "closed" | "all"
|
||||
---@field mention? string filter by mention
|
||||
---@field milestone? string filter by milestone
|
||||
M.gh_issue = {
|
||||
title = " Issues",
|
||||
finder = "gh_issue",
|
||||
format = "gh_format",
|
||||
preview = "gh_preview",
|
||||
sort = { fields = { "score:desc", "idx" } },
|
||||
supports_live = true,
|
||||
live = true,
|
||||
confirm = "gh_actions",
|
||||
win = {
|
||||
input = {
|
||||
keys = {
|
||||
["<a-b>"] = { "gh_browse", mode = { "n", "i" } },
|
||||
["<c-y>"] = { "gh_yank", mode = { "n", "i" } },
|
||||
},
|
||||
},
|
||||
list = {
|
||||
keys = {
|
||||
["y"] = { "gh_yank", mode = { "n", "x" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
---@class snacks.picker.gh.pr.Config: snacks.picker.gh.Config
|
||||
---@field state "open" | "closed" | "merged" | "all"
|
||||
---@field draft? boolean filter draft PRs
|
||||
---@field base? string filter by base branch
|
||||
M.gh_pr = {
|
||||
title = " Pull Requests",
|
||||
finder = "gh_pr",
|
||||
format = "gh_format",
|
||||
preview = "gh_preview",
|
||||
sort = { fields = { "score:desc", "idx" } },
|
||||
supports_live = true,
|
||||
live = true,
|
||||
confirm = "gh_actions",
|
||||
win = {
|
||||
input = {
|
||||
keys = {
|
||||
["<a-b>"] = { "gh_browse", mode = { "n", "i" } },
|
||||
["<c-y>"] = { "gh_yank", mode = { "n", "i" } },
|
||||
},
|
||||
},
|
||||
list = {
|
||||
keys = {
|
||||
["y"] = { "gh_yank", mode = { "n", "x" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
---@class snacks.picker.gh.diff.Config: snacks.picker.Config
|
||||
---@field group? boolean group changes by file (when false, show individual hunks)
|
||||
---@field pr number number PR number to diff against
|
||||
---@field repo? string GitHub repository (owner/repo). Defaults to current git repo
|
||||
M.gh_diff = {
|
||||
title = " Pull Request Diff",
|
||||
group = true,
|
||||
finder = "gh_diff",
|
||||
format = "file",
|
||||
preview = "diff",
|
||||
}
|
||||
|
||||
---@class snacks.picker.gh.reactions.Config: snacks.picker.Config
|
||||
---@field number number issue or PR number
|
||||
---@field repo string GitHub repository (owner/repo). Defaults to current git repo
|
||||
M.gh_reactions = {
|
||||
layout = { preset = "select", layout = { max_width = 50 } },
|
||||
title = " Reactions",
|
||||
main = { current = true },
|
||||
group = true,
|
||||
finder = "gh_reactions",
|
||||
format = "gh_format_reaction",
|
||||
}
|
||||
|
||||
---@class snacks.picker.gh.labels.Config: snacks.picker.Config
|
||||
---@field number number issue or PR number
|
||||
---@field repo string GitHub repository (owner/repo). Defaults to current git repo
|
||||
M.gh_labels = {
|
||||
layout = { preset = "select", layout = { max_width = 50 } },
|
||||
title = " Labels",
|
||||
main = { current = true },
|
||||
group = true,
|
||||
finder = "gh_labels",
|
||||
format = "gh_format_label",
|
||||
}
|
||||
|
||||
--- Git arguments are use like this:
|
||||
--- * git [<cmd_args>] <cmd> [<args>]
|
||||
--- * cmd may be `status`, `log`, `diff`, etc.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ local M = {}
|
|||
---@field action snacks.picker.Action.fn
|
||||
---@field desc? string
|
||||
---@field name? string
|
||||
---@field [string] any additional fields
|
||||
|
||||
---@param picker snacks.Picker
|
||||
function M.get(picker)
|
||||
|
|
|
|||
|
|
@ -803,6 +803,14 @@ function M:hist(forward)
|
|||
self.input:set(hist.pattern, hist.search)
|
||||
end
|
||||
|
||||
--- Clears the selection, set the target to the current item,
|
||||
--- and refresh the finder and matcher.
|
||||
function M:refresh()
|
||||
self.list:set_selected()
|
||||
self.list:set_target()
|
||||
self:find({ refresh = true })
|
||||
end
|
||||
|
||||
--- Check if the finder and/or matcher need to run,
|
||||
--- based on the current pattern and search string.
|
||||
---@param opts? { on_done?: fun(), refresh?: boolean }
|
||||
|
|
|
|||
|
|
@ -103,6 +103,21 @@ function M.new(picker)
|
|||
|
||||
self.win:on("WinClosed", function()
|
||||
self:clear(self.win.buf)
|
||||
vim.schedule(function()
|
||||
local ei = vim.o.eventignore
|
||||
vim.o.eventignore = "all"
|
||||
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if
|
||||
vim.api.nvim_buf_is_loaded(buf)
|
||||
and vim.b[buf].snacks_picker_loaded
|
||||
and not vim.bo[buf].buflisted
|
||||
and #vim.fn.win_findbuf(buf) == 0
|
||||
then
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end
|
||||
end
|
||||
vim.o.eventignore = ei
|
||||
end)
|
||||
end, { win = true })
|
||||
|
||||
self.preview = Snacks.picker.config.preview(opts)
|
||||
|
|
@ -186,7 +201,7 @@ function M:show(picker, opts)
|
|||
})
|
||||
)
|
||||
if not ok then
|
||||
self:notify(err, "error")
|
||||
self:notify(err --[[@as string]], "error")
|
||||
end
|
||||
if self.win.buf ~= buf then
|
||||
self:clear(buf)
|
||||
|
|
@ -221,6 +236,9 @@ end
|
|||
function M:set_buf(buf)
|
||||
vim.b[buf].snacks_previewed = true
|
||||
self.win:set_buf(buf)
|
||||
if self.item and self.item.wo and self.win:win_valid() then
|
||||
Snacks.util.wo(self.win.win, self.item.wo)
|
||||
end
|
||||
end
|
||||
|
||||
function M:reset()
|
||||
|
|
@ -282,7 +300,6 @@ function M:highlight(opts)
|
|||
filename = opts.file,
|
||||
})
|
||||
end
|
||||
self:check_big()
|
||||
local lang = Snacks.util.get_lang(opts.lang or ft)
|
||||
if lang == "markdown" then
|
||||
return self:markdown()
|
||||
|
|
@ -369,26 +386,11 @@ function M:loc()
|
|||
self:wo({ cursorline = true })
|
||||
end
|
||||
end)
|
||||
else -- no position info, go to top
|
||||
vim.api.nvim_win_set_cursor(self.win.win, { 1, 0 })
|
||||
end
|
||||
end
|
||||
|
||||
function M:check_big()
|
||||
local big = self:is_big()
|
||||
vim.b[self.win.buf].snacks_scroll = not big
|
||||
end
|
||||
|
||||
function M:is_big()
|
||||
local lines = vim.api.nvim_buf_line_count(self.win.buf)
|
||||
if lines > 2000 then
|
||||
return true
|
||||
end
|
||||
local path = self.item and self.item.file and Snacks.picker.util.path(self.item)
|
||||
if path and vim.fn.getfsize(path) > 1.5 * 1024 * 1024 then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
---@param lines string[]
|
||||
---@param offset? number
|
||||
function M:set_lines(lines, offset)
|
||||
|
|
|
|||
|
|
@ -91,9 +91,13 @@ function M.file(ctx)
|
|||
|
||||
-- used by some LSP servers that load buffers with custom URIs
|
||||
if ctx.item.buf and vim.uri_from_bufnr(ctx.item.buf):sub(1, 4) ~= "file" then
|
||||
vim.fn.bufload(ctx.item.buf)
|
||||
if not vim.api.nvim_buf_is_loaded(ctx.item.buf) then
|
||||
vim.b[ctx.item.buf].snacks_picker_loaded = true
|
||||
vim.fn.bufload(ctx.item.buf)
|
||||
end
|
||||
elseif ctx.item.file and ctx.item.file:find("^%w+://") then
|
||||
ctx.item.buf = vim.fn.bufadd(ctx.item.file)
|
||||
vim.b[ctx.item.buf].snacks_picker_loaded = true
|
||||
vim.fn.bufload(ctx.item.buf)
|
||||
end
|
||||
|
||||
|
|
|
|||
300
lua/snacks/picker/source/gh.lua
Normal file
300
lua/snacks/picker/source/gh.lua
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
local Api = require("snacks.gh.api")
|
||||
local Actions = require("snacks.gh.actions").actions
|
||||
|
||||
local M = {}
|
||||
|
||||
M.actions = setmetatable({}, {
|
||||
__index = function(t, k)
|
||||
if type(k) ~= "string" then
|
||||
return
|
||||
end
|
||||
if not Actions[k] then
|
||||
return nil
|
||||
end
|
||||
---@type snacks.picker.Action
|
||||
local action = {
|
||||
desc = Actions[k].desc,
|
||||
action = function(picker, item, action)
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
return Actions[k].action(item, {
|
||||
picker = picker,
|
||||
items = picker:selected({ fallback = true }),
|
||||
action = action,
|
||||
})
|
||||
end,
|
||||
}
|
||||
rawset(t, k, action)
|
||||
return action
|
||||
end,
|
||||
})
|
||||
|
||||
---@param opts snacks.picker.gh.list.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.gh(opts, ctx)
|
||||
if ctx.filter.search ~= "" then
|
||||
opts.search = ctx.filter.search
|
||||
end
|
||||
---@async
|
||||
return function(cb)
|
||||
Api.list(opts.type, function(items)
|
||||
for _, item in ipairs(items) do
|
||||
cb(item)
|
||||
end
|
||||
end, opts):wait()
|
||||
end
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.issue(opts, ctx)
|
||||
return M.gh(
|
||||
vim.tbl_extend("force", {
|
||||
type = "issue",
|
||||
}, opts),
|
||||
ctx
|
||||
)
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.pr(opts, ctx)
|
||||
return M.gh(
|
||||
vim.tbl_extend("force", {
|
||||
type = "pr",
|
||||
}, opts),
|
||||
ctx
|
||||
)
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.gh.diff.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.diff(opts, ctx)
|
||||
opts = opts or {}
|
||||
if not opts.pr then
|
||||
Snacks.notify.error("snacks.picker.gh.diff: `opts.pr` is required")
|
||||
return {}
|
||||
end
|
||||
local cwd = ctx:git_root()
|
||||
local args = { "pr", "diff", tostring(opts.pr) }
|
||||
if opts.repo then
|
||||
vim.list_extend(args, { "--repo", opts.repo })
|
||||
end
|
||||
return require("snacks.picker.source.diff").diff(
|
||||
ctx:opts({
|
||||
cmd = "gh",
|
||||
args = args,
|
||||
cwd = cwd,
|
||||
}),
|
||||
ctx
|
||||
)
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.gh.reactions.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.reactions(opts, ctx)
|
||||
if not opts.repo then
|
||||
Snacks.notify.error("snacks.picker.gh.reactions: `opts.repo` is required")
|
||||
return {}
|
||||
end
|
||||
if not opts.number then
|
||||
Snacks.notify.error("snacks.picker.gh.reactions: `opts.number` is required")
|
||||
return {}
|
||||
end
|
||||
|
||||
local all = { "+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes" }
|
||||
---@async
|
||||
return function(cb)
|
||||
local items = {} ---@type table<string, snacks.picker.finder.Item>
|
||||
local user = Api.user()
|
||||
|
||||
---@type {user:snacks.gh.User, content:string}[]
|
||||
local reactions = Api.request_sync({
|
||||
endpoint = ("/repos/%s/issues/%s/reactions"):format(opts.repo, opts.number),
|
||||
})
|
||||
|
||||
for _, r in ipairs(reactions) do
|
||||
if r.user.login == user.login then
|
||||
items[r.content] = setmetatable({
|
||||
text = r.content,
|
||||
reaction = r.content,
|
||||
added = true,
|
||||
}, { __index = r })
|
||||
end
|
||||
end
|
||||
|
||||
for _, reaction in ipairs(all) do
|
||||
cb(items[reaction] or {
|
||||
text = reaction,
|
||||
reaction = reaction,
|
||||
added = false,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param opts snacks.picker.gh.labels.Config
|
||||
---@type snacks.picker.finder
|
||||
function M.labels(opts, ctx)
|
||||
if not opts.repo then
|
||||
Snacks.notify.error("snacks.picker.gh.labels: `opts.repo` is required")
|
||||
return {}
|
||||
end
|
||||
if not opts.number then
|
||||
Snacks.notify.error("snacks.picker.gh.labels: `opts.number` is required")
|
||||
return {}
|
||||
end
|
||||
|
||||
---@async
|
||||
return function(cb)
|
||||
---@type {labels: snacks.gh.Label[]}
|
||||
local repo = Api.fetch_sync({
|
||||
fields = { "labels" },
|
||||
args = { "repo", "view", opts.repo },
|
||||
})
|
||||
local item = Api.get(opts)
|
||||
assert(item, "Failed to get item for labels")
|
||||
local added = {} ---@type table<string, boolean>
|
||||
for _, label in ipairs(item.labels or {}) do
|
||||
added[label.name] = true
|
||||
end
|
||||
repo.labels = repo.labels or {}
|
||||
table.sort(repo.labels, function(a, b)
|
||||
if added[a.name] ~= added[b.name] then
|
||||
return added[a.name] == true
|
||||
end
|
||||
return a.name:lower() < b.name:lower()
|
||||
end)
|
||||
|
||||
for _, r in ipairs(repo.labels or {}) do
|
||||
cb({
|
||||
text = r.name,
|
||||
label = r.name,
|
||||
added = added[r.name] == true,
|
||||
item = r,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param item snacks.picker.gh.Item
|
||||
---@type snacks.picker.format
|
||||
function M.format(item, picker)
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
local a = Snacks.picker.util.align
|
||||
|
||||
local config = require("snacks.gh").config()
|
||||
-- Status Icon
|
||||
local icons = config.icons[item.type]
|
||||
local status = icons[item.status] and item.status or "other"
|
||||
if status then
|
||||
local icon = icons[status]
|
||||
local icon_hl = "SnacksGh" .. Snacks.picker.util.title(item.type) .. Snacks.picker.util.title(status)
|
||||
ret[#ret + 1] = { a(icon, 2), icon_hl }
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
|
||||
-- Number / Hash
|
||||
if item.hash then
|
||||
ret[#ret + 1] = { a(item.hash, 8), "SnacksPickerDimmed" }
|
||||
end
|
||||
|
||||
-- Updated At
|
||||
-- if item.updated then
|
||||
-- ret[#ret + 1] = { a(Snacks.picker.util.reltime(item.updated), 12), "SnacksPickerGitDate" }
|
||||
-- end
|
||||
|
||||
-- Title
|
||||
if item.title then
|
||||
item.msg = item.title
|
||||
Snacks.picker.highlight.extend(ret, Snacks.picker.format.commit_message(item, picker))
|
||||
end
|
||||
|
||||
-- Author
|
||||
if item.author and not item.item.author.is_bot then
|
||||
ret[#ret + 1] = { " ", nil }
|
||||
ret[#ret + 1] = { "@" .. item.author, "SnacksPickerGitAuthor" }
|
||||
end
|
||||
|
||||
-- Labels
|
||||
for _, label in ipairs(item.item.labels or {}) do
|
||||
ret[#ret + 1] = { " ", nil }
|
||||
local color = label.color or "888888"
|
||||
local badge = Snacks.picker.highlight.badge(label.name, "#" .. color)
|
||||
vim.list_extend(ret, badge)
|
||||
end
|
||||
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param ctx snacks.picker.preview.ctx
|
||||
function M.preview(ctx)
|
||||
local config = require("snacks.gh").config()
|
||||
local item = ctx.item
|
||||
item.wo = config.wo
|
||||
item.bo = config.bo
|
||||
item.preview_title = ("%s %s %s"):format(
|
||||
config.icons.logo,
|
||||
(item.type == "issue" and "Issue" or "PR"),
|
||||
(item.hash or "")
|
||||
)
|
||||
return Snacks.picker.preview.file(ctx)
|
||||
end
|
||||
|
||||
---@type snacks.picker.format
|
||||
function M.format_label(item, picker)
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
local added = item.added
|
||||
if picker.list:is_selected(item) then
|
||||
added = not added -- reflect the change that will happen on action
|
||||
end
|
||||
ret[#ret + 1] = { added and " " or " ", "SnacksPickerDelim" }
|
||||
ret[#ret + 1] = { " " }
|
||||
local color = item.item.color or "888888"
|
||||
local badge = Snacks.picker.highlight.badge(item.label, "#" .. color)
|
||||
vim.list_extend(ret, badge)
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param item snacks.picker.gh.Action
|
||||
---@type snacks.picker.format
|
||||
function M.format_action(item, picker)
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
|
||||
if item.action.icon then
|
||||
ret[#ret + 1] = { item.action.icon, "Special" }
|
||||
ret[#ret + 1] = { " " }
|
||||
end
|
||||
|
||||
local count = picker:count()
|
||||
local idx = tostring(item.idx)
|
||||
idx = (" "):rep(#tostring(count) - #idx) .. idx
|
||||
ret[#ret + 1] = { idx .. ".", "SnacksPickerIdx" }
|
||||
|
||||
ret[#ret + 1] = { " " }
|
||||
|
||||
if item.desc then
|
||||
ret[#ret + 1] = { item.desc or item.name }
|
||||
Snacks.picker.highlight.highlight(ret, {
|
||||
["#%d+"] = "Number",
|
||||
})
|
||||
end
|
||||
return ret
|
||||
end
|
||||
|
||||
---@type snacks.picker.format
|
||||
function M.format_reaction(item, picker)
|
||||
local config = require("snacks.gh").config()
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
local name = item.reaction
|
||||
name = name == "+1" and "thumbs_up" or name == "-1" and "thumbs_down" or name
|
||||
local added = item.added
|
||||
if picker.list:is_selected(item) then
|
||||
added = not added -- reflect the change that will happen on action
|
||||
end
|
||||
ret[#ret + 1] = { added and " " or " ", "SnacksPickerDelim" }
|
||||
ret[#ret + 1] = { " " }
|
||||
ret[#ret + 1] = { config.icons.reactions[name] or name }
|
||||
return ret
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -14,9 +14,7 @@ M.actions = {
|
|||
local current = item.file
|
||||
os.remove(current)
|
||||
os.remove(current .. ".meta")
|
||||
picker.list:set_selected()
|
||||
picker.list:set_target()
|
||||
picker:find()
|
||||
picker:refresh()
|
||||
end,
|
||||
scratch_new = function(picker)
|
||||
picker:close()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@
|
|||
---@field diagnostics_buffer fun(opts?: snacks.picker.diagnostics.Config|{}): snacks.Picker
|
||||
---@field explorer fun(opts?: snacks.picker.explorer.Config|{}): snacks.Picker
|
||||
---@field files fun(opts?: snacks.picker.files.Config|{}): snacks.Picker
|
||||
---@field gh_diff fun(opts?: snacks.picker.gh.diff.Config|{}): snacks.Picker
|
||||
---@field gh_issue fun(opts?: snacks.picker.gh.issue.Config|{}): snacks.Picker
|
||||
---@field gh_labels fun(opts?: snacks.picker.gh.labels.Config|{}): snacks.Picker
|
||||
---@field gh_pr fun(opts?: snacks.picker.gh.pr.Config|{}): snacks.Picker
|
||||
---@field gh_reactions fun(opts?: snacks.picker.gh.reactions.Config|{}): snacks.Picker
|
||||
---@field git_branches fun(opts?: snacks.picker.git.branches.Config|{}): snacks.Picker
|
||||
---@field git_diff fun(opts?: snacks.picker.git.diff.Config|{}): snacks.Picker
|
||||
---@field git_files fun(opts?: snacks.picker.git.files.Config|{}): snacks.Picker
|
||||
|
|
|
|||
|
|
@ -204,10 +204,25 @@ function M.visual()
|
|||
end
|
||||
|
||||
---@param str string
|
||||
---@param data table<string, string>
|
||||
---@param data table<string, string|boolean|number>|table<string, string|boolean|number>[]
|
||||
---@param opts? {prefix?: string, indent?: boolean, offset?: number[]}
|
||||
function M.tpl(str, data, opts)
|
||||
opts = opts or {}
|
||||
|
||||
local function get(key)
|
||||
if not vim.tbl_isempty(data) and svim.islist(data) and not getmetatable(data) then
|
||||
for _, d in ipairs(data) do
|
||||
if d[key] ~= nil then
|
||||
return d[key]
|
||||
end
|
||||
end
|
||||
else
|
||||
if data[key] ~= nil then
|
||||
return data[key]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local ret = (
|
||||
str:gsub(
|
||||
"(" .. vim.pesc(opts.prefix or "") .. "%b{}" .. ")",
|
||||
|
|
@ -215,7 +230,7 @@ function M.tpl(str, data, opts)
|
|||
function(w)
|
||||
local inner = w:sub(2 + #(opts.prefix or ""), -2)
|
||||
local key, default = inner:match("^(.-):(.*)$")
|
||||
local ret = data[key or inner]
|
||||
local ret = get(key or inner)
|
||||
if ret == "" and default then
|
||||
return default
|
||||
end
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ function M.render(buf, opts)
|
|||
end
|
||||
|
||||
if opts.images ~= false then
|
||||
vim.b[buf].snacks_image_conceal = true
|
||||
Snacks.image.doc.attach(buf)
|
||||
end
|
||||
|
||||
|
|
|
|||
134
lua/snacks/picker/util/spinner.lua
Normal file
134
lua/snacks/picker/util/spinner.lua
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
---@class snacks.util.spinner.Opts
|
||||
---@field extmark? fun(spinner:string): vim.api.keyset.set_extmark
|
||||
|
||||
---@class snacks.util.Spinner
|
||||
---@field buf number
|
||||
---@field opts snacks.util.spinner.Opts
|
||||
---@field timer? uv.uv_timer_t
|
||||
---@field extmark_id? number
|
||||
local M = {}
|
||||
M.__index = M
|
||||
|
||||
local ns = vim.api.nvim_create_namespace("snacks.picker.util.spinner")
|
||||
|
||||
---@param opts? snacks.util.spinner.Opts
|
||||
---@param buf number
|
||||
function M.new(buf, opts)
|
||||
local self = setmetatable({}, M)
|
||||
self.buf = buf
|
||||
self.opts = opts or {}
|
||||
self:start()
|
||||
return self
|
||||
end
|
||||
|
||||
function M:start()
|
||||
if self:running() then
|
||||
return
|
||||
end
|
||||
self:stop()
|
||||
if not self:buf_valid() then
|
||||
return
|
||||
end
|
||||
self.timer = assert(vim.uv.new_timer())
|
||||
self.timer:start(0, 60, function()
|
||||
vim.schedule(function()
|
||||
self:step()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function M:buf_valid()
|
||||
return self.buf and vim.api.nvim_buf_is_valid(self.buf)
|
||||
end
|
||||
|
||||
function M:step()
|
||||
if not self:running() then
|
||||
return
|
||||
end
|
||||
if not self:buf_valid() then
|
||||
return self:stop()
|
||||
end
|
||||
local lines = vim.api.nvim_buf_get_lines(self.buf, 0, -1, false)
|
||||
local row = math.max(#lines - 1, 0)
|
||||
while row > 0 and lines[row + 1]:match("^%s*$") do
|
||||
row = row - 1
|
||||
end
|
||||
|
||||
local spinner = Snacks.util.spinner()
|
||||
|
||||
---@type vim.api.keyset.set_extmark
|
||||
local extmark = {}
|
||||
if type(self.opts.extmark) == "function" then
|
||||
extmark = self.opts.extmark(spinner)
|
||||
else
|
||||
if row > 0 then
|
||||
extmark.virt_lines = { { { spinner, "SnacksPickerSpinner" } } }
|
||||
else
|
||||
extmark.virt_text = { { spinner, "SnacksPickerSpinner" } }
|
||||
end
|
||||
end
|
||||
extmark.id = self.extmark_id
|
||||
extmark.priority = 1000
|
||||
self.extmark_id = vim.api.nvim_buf_set_extmark(self.buf, ns, row, 0, extmark)
|
||||
end
|
||||
|
||||
function M:running()
|
||||
return self.timer and not self.timer:is_closing()
|
||||
end
|
||||
|
||||
function M:stop()
|
||||
if self.timer and not self.timer:is_closing() then
|
||||
self.timer:stop()
|
||||
self.timer:close()
|
||||
self.timer = nil
|
||||
end
|
||||
if self:buf_valid() then
|
||||
vim.api.nvim_buf_clear_namespace(self.buf, ns, 0, -1)
|
||||
end
|
||||
end
|
||||
|
||||
---@param msg? string
|
||||
---@param opts? snacks.win.Config
|
||||
function M.loading(msg, opts)
|
||||
opts = opts or {}
|
||||
local parent_win = opts.win or vim.api.nvim_get_current_win()
|
||||
msg = msg or "Loading..."
|
||||
msg = " " .. msg
|
||||
opts = Snacks.win.resolve({
|
||||
backdrop = false,
|
||||
win = vim.api.nvim_get_current_win(),
|
||||
focusable = false,
|
||||
enter = false,
|
||||
relative = "win",
|
||||
zindex = vim.api.nvim_win_get_config(parent_win).zindex + 1,
|
||||
width = vim.api.nvim_strwidth(msg) + 1,
|
||||
height = 1,
|
||||
border = "rounded",
|
||||
text = msg,
|
||||
}, opts)
|
||||
local win = Snacks.win(opts)
|
||||
local spinner ---@type snacks.util.Spinner
|
||||
win:on("WinClosed", function(_, ev)
|
||||
if ev.match == tostring(parent_win) then
|
||||
win:close()
|
||||
spinner:stop()
|
||||
end
|
||||
end)
|
||||
spinner = M.new(win.buf, {
|
||||
extmark = function(text)
|
||||
return {
|
||||
virt_text = { { text, "SnacksPickerSpinner" } },
|
||||
virt_text_pos = "overlay",
|
||||
virt_text_win_col = 1,
|
||||
}
|
||||
end,
|
||||
})
|
||||
local stop = spinner.stop
|
||||
spinner.stop = function()
|
||||
stop(spinner)
|
||||
win:close()
|
||||
end
|
||||
return spinner
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -309,6 +309,8 @@ function M._get()
|
|||
components[3] = " "
|
||||
end
|
||||
end
|
||||
components[1] = vim.b[buf].snacks_statuscolumn_left ~= false and components[1] or ""
|
||||
components[3] = vim.b[buf].snacks_statuscolumn_right ~= false and components[3] or ""
|
||||
|
||||
local ret = table.concat(components, "")
|
||||
return "%@v:lua.require'snacks.statuscolumn'.click_fold@" .. ret .. "%T"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
local Async = require("snacks.picker.util.async")
|
||||
|
||||
---@class snacks.spawn
|
||||
local M = {}
|
||||
|
||||
|
|
@ -9,6 +11,7 @@ local uv = vim.uv or vim.loop
|
|||
---@field timeout? number
|
||||
---@field run? boolean
|
||||
---@field debug? boolean
|
||||
---@field input? string
|
||||
---@field on_stdout? fun(proc: snacks.spawn.Proc, data: string)
|
||||
---@field on_stderr? fun(proc: snacks.spawn.Proc, data: string)
|
||||
---@field on_exit? fun(proc: snacks.spawn.Proc, err: boolean)
|
||||
|
|
@ -22,11 +25,14 @@ local uv = vim.uv or vim.loop
|
|||
---@field handle? uv.uv_process_t
|
||||
---@field stdout uv.uv_pipe_t
|
||||
---@field stderr uv.uv_pipe_t
|
||||
---@field stdin? uv.uv_pipe_t
|
||||
---@field code? number
|
||||
---@field signal? number
|
||||
---@field timer? uv.uv_timer_t
|
||||
---@field aborted? boolean
|
||||
---@field data table<uv.uv_pipe_t, string[]>
|
||||
---@field async? snacks.picker.Async
|
||||
---@field did_exit? boolean
|
||||
local Proc = {}
|
||||
Proc.__index = Proc
|
||||
|
||||
|
|
@ -57,9 +63,8 @@ end
|
|||
function Proc:kill(signal)
|
||||
close(self.stdout)
|
||||
close(self.stderr)
|
||||
if not self.handle then
|
||||
if self:running() then
|
||||
self.aborted = true
|
||||
elseif self:running() then
|
||||
self.handle:kill(signal or "sigterm")
|
||||
end
|
||||
end
|
||||
|
|
@ -100,13 +105,33 @@ function Proc:debug(opts)
|
|||
return Snacks.debug.cmd(opts)
|
||||
end
|
||||
|
||||
---@async
|
||||
function Proc:wait()
|
||||
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
|
||||
self.async:suspend()
|
||||
end
|
||||
end
|
||||
|
||||
function Proc:run()
|
||||
assert(not self.handle, "already running")
|
||||
if self.aborted then
|
||||
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.stdout = assert(uv.new_pipe())
|
||||
self.stderr = assert(uv.new_pipe())
|
||||
self.stdin = self.opts.input and assert(uv.new_pipe()) or nil
|
||||
self.data = { [self.stdout] = {}, [self.stderr] = {} }
|
||||
if self.opts.debug then
|
||||
vim.schedule(function()
|
||||
|
|
@ -114,7 +139,7 @@ function Proc:run()
|
|||
end)
|
||||
end
|
||||
local opts = vim.tbl_deep_extend("force", self.opts, {
|
||||
stdio = { nil, self.stdout, self.stderr },
|
||||
stdio = { self.stdin, self.stdout, self.stderr },
|
||||
hide = true,
|
||||
args = vim.tbl_map(tostring, self.opts.args or {}),
|
||||
})
|
||||
|
|
@ -130,6 +155,13 @@ function Proc:run()
|
|||
close(self.stderr)
|
||||
return self:on_exit()
|
||||
end
|
||||
|
||||
if self.stdin and self.opts.input then
|
||||
self.stdin:write(self.opts.input)
|
||||
self.stdin:shutdown()
|
||||
self.stdin:close()
|
||||
end
|
||||
|
||||
if self.opts.timeout then
|
||||
self.timer = assert(uv.new_timer())
|
||||
self.timer:start(self.opts.timeout, 0, function()
|
||||
|
|
@ -191,6 +223,10 @@ function Proc:on_exit()
|
|||
close(self.stderr)
|
||||
if self.opts.on_exit then
|
||||
self.opts.on_exit(self, self.code ~= 0 or self.signal ~= 0 or self.aborted or false)
|
||||
self.did_exit = true
|
||||
if self.async then
|
||||
self.async:resume()
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue