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:
Folke Lemaitre 2025-11-01 12:12:06 +01:00 committed by GitHub
parent 9ad41782ec
commit 85b8ec2109
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2732 additions and 55 deletions

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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|{}

View file

@ -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() })

View file

@ -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",
},

View file

@ -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",

View file

@ -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.

View file

@ -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)

View file

@ -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 }

View file

@ -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)

View file

@ -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

View 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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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"

View file

@ -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