feat(gh): added reviews and nice diffs to gh buffer views. See #2411

This commit is contained in:
Folke Lemaitre 2025-11-02 09:24:27 +01:00
parent 6f60105302
commit 1335ca1956
No known key found for this signature in database
GPG key ID: 9B52594D560070AB
8 changed files with 735 additions and 107 deletions

View file

@ -56,6 +56,7 @@ local config = {
"headRefName",
"mergedAt",
"statusCheckRollup",
"reviews",
},
---@param item snacks.picker.gh.Item
transform = function(item)
@ -76,6 +77,42 @@ local function cache_set(item)
return item
end
---@generic T
---@param fn fun(cb:fun(proc:snacks.spawn.Proc, data?:any), opts:T): snacks.spawn.Proc
---@return fun(opts:T): any?
local function wrap_sync(fn)
---@async
return function(opts)
local ret ---@type any
fn(function(_, data)
ret = data
end, opts):wait()
return ret
end
end
--- Cleanup GraphQL internal nodes and reaction groups
---@param ret table<string, any>
local function clean_graphql(ret)
for k, v in pairs(ret) do
if type(v) == "table" then
clean_graphql(v)
end
if k == "reactionGroups" and type(v) == "table" then
---@param r snacks.gh.Reaction
ret[k] = vim.tbl_filter(function(r)
return r.users and r.users.totalCount and r.users.totalCount > 0
end, v)
ret[k] = #ret[k] > 0 and ret[k] or nil
elseif type(v) == "table" and type(v.nodes) == "table" and vim.tbl_count(v) == 1 then
ret[k] = v.nodes
elseif v == vim.NIL then
ret[k] = nil
end
end
return ret
end
---@param what "issue" | "pr"
---@param key "list" | "view"
local function get_opts(what, key)
@ -153,16 +190,7 @@ function M.cmd(cb, opts)
})
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
M.cmd_sync = wrap_sync(M.cmd)
---@param cb fun(proc: snacks.spawn.Proc, data?: unknown)
---@param opts snacks.gh.api.Fetch
@ -176,16 +204,7 @@ function M.fetch(cb, opts)
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
M.fetch_sync = wrap_sync(M.fetch)
---@param cb fun(proc: snacks.spawn.Proc, data?: table)
---@param opts snacks.gh.api.Api
@ -193,16 +212,16 @@ 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] = "-"
vim.list_extend(args, { "--input", "-" })
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))
vim.list_extend(args, { "--raw-field", ("%s=%s"):format(k, tostring(v)) })
end
for k, v in pairs(opts.params or {}) do
vim.list_extend(args, { "--field", ("%s=%s"):format(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))
vim.list_extend(args, { "--header", ("%s:%s"):format(k, tostring(v)) })
end
return M.cmd(function(proc, data)
cb(proc, data and data:find("%S") and proc:json() or nil)
@ -212,16 +231,43 @@ function M.request(cb, opts)
on_error = opts.on_error,
})
end
M.request_sync = wrap_sync(M.request)
---@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
---@param cb fun(proc: snacks.spawn.Proc, data?: table)
---@param opts snacks.gh.api.GraphQL
function M.graphql(cb, opts)
opts = Snacks.config.merge(vim.deepcopy(opts), {
endpoint = "graphql",
fields = {
query = opts.query,
},
})
return M.request(function(proc, data)
if not data then
return
end
if data.errors then
local msgs = {} ---@type string[]
for _, err in ipairs(data.errors) do
msgs[#msgs + 1] = err.message
end
vim.schedule(function()
Snacks.debug.cmd({
header = "GH GraphQL Error",
cmd = { "gh", "api", "graphql" },
footer = table.concat(msgs, "\n"),
level = vim.log.levels.ERROR,
})
if opts.on_error then
opts.on_error(proc, table.concat(msgs, "\n"))
end
end)
return
end
cb(proc, clean_graphql(data.data))
end, opts)
end
M.graphql_sync = wrap_sync(M.graphql)
---@async
function M.user()
@ -281,14 +327,35 @@ function M.view(item, cb, opts)
end
local args = { item.type, "view", tostring(item.number) }
---@param data? snacks.gh.Item
return M.fetch(function(_, data)
if not data then
local need_reviews = item.type == "pr" and vim.tbl_contains(todo, "comments")
local it ---@type snacks.gh.Item?
local pending = need_reviews and 2 or 1
---@param data? snacks.gh.Item|{}
local function handler(data)
it = data and vim.tbl_extend("force", it or {}, data or {}) or it
pending = pending - 1
if pending > 0 then
return
end
if not it then
return cb()
end
item = Item.new(item, api_opts)
item:update(data, todo)
item:update(it, todo)
cb(cache_set(item), true)
end
if need_reviews then
todo = vim.tbl_filter(function(f)
return f ~= "comments" and f ~= "reviews"
end, todo)
M.comments(item, handler)
end
---@param data? snacks.gh.Item
return M.fetch(function(_, data)
handler(data)
end, {
args = args,
fields = todo,
@ -314,4 +381,78 @@ function M.refresh(item)
end
end
---@param cb fun(data?: {comments: snacks.gh.Comment[], reviews: snacks.gh.Review[]})
---@param item snacks.gh.api.View
function M.comments(item, cb)
local owner, name = item.repo:match("^(.-)/(.-)$")
return M.graphql(function(_, data)
if not data then
return cb()
end
cb(data.repository.pullRequest)
end, {
params = {
owner = owner,
name = name,
number = item.number,
},
query = [[
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviews(first: 100) {
nodes {
id
author { login }
authorAssociation
body
state
commit { oid }
submittedAt
reactionGroups {
content
users { totalCount }
}
comments(first: 50) {
nodes {
id
body
path
diffHunk
line
startLine
originalLine
originalStartLine
createdAt
subjectType
author { login }
replyTo { id }
reactionGroups {
content
users { totalCount }
}
}
}
}
}
comments(first: 100) {
nodes {
id
body
author { login }
authorAssociation
createdAt
reactionGroups {
content
users { totalCount }
}
}
}
}
}
}
]],
})
end
return M

View file

@ -46,6 +46,7 @@ local defaults = {
foldmethod = "expr",
concealcursor = "n",
conceallevel = 2,
list = false,
winhighlight = Snacks.util.winhl({
Normal = "SnacksGhNormal",
NormalFloat = "SnacksGhNormalFloat",
@ -56,6 +57,10 @@ local defaults = {
},
---@type vim.bo|{}
bo = {},
diff = {
min = 4, -- minimum number of lines changed to show diff
wrap = 80, -- wrap diff lines at this length
},
-- stylua: ignore
icons = {
logo = "",
@ -82,6 +87,13 @@ local defaults = {
draft = "",
other = "",
},
review = {
approved = "",
changes_requested = "",
commented = "",
dismissed = "",
pending = "",
},
merge_status = {
clean = "",
dirty = "",
@ -101,6 +113,16 @@ local defaults = {
},
}
local function diff_linenr(hl)
local fg = Snacks.util.color({ hl, "SnacksGhNormalFloat", "Normal" })
local bg = Snacks.util.color({ hl, "SnacksGhNormalFloat", "Normal" }, "bg")
bg = bg or vim.o.background == "dark" and "#1e1e1e" or "#f5f5f5"
return {
fg = fg,
bg = Snacks.util.blend(fg, bg, 0.1),
}
end
Snacks.util.set_hl({
Normal = "NormalFloat",
NormalFloat = "NormalFloat",
@ -139,6 +161,18 @@ Snacks.util.set_hl({
CheckSuccess = "SnacksGhGreen",
CheckFailure = "SnacksGhRed",
CheckSkipped = "SnacksGhStat",
ReviewApproved = "SnacksGhGreen",
ReviewChangesRequested = "DiagnosticError",
ReviewCommented = {},
ReviewPending = "DiagnosticWarn",
CommentAction = "@property",
DiffHeader = "DiagnosticVirtualTextInfo",
DiffAdd = "DiffAdd",
DiffDelete = "DiffDelete",
DiffContext = "DiffChange",
DiffAddLineNr = diff_linenr("DiffAdd"),
DiffDeleteLineNr = diff_linenr("DiffDelete"),
DiffContextLineNr = diff_linenr("DiffChange"),
Stat = { fg = Snacks.util.color("SignColumn") },
}, { default = true, prefix = "SnacksGh" })

View file

@ -2,12 +2,33 @@
---@field opts snacks.gh.api.Config
local M = {}
local time_fields = {
created = "createdAt",
updated = "updatedAt",
closed = "closedAt",
merged = "mergedAt",
submitted = "submittedAt",
}
---@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" }
---@generic T: table
---@param obj T
---@return T
local function wrap_ts(obj)
return setmetatable(obj, {
__index = function(tbl, key)
if time_fields[key] then
local field = time_fields[key]
local value = tbl[field] or tbl[field:gsub("At", "_at")]
return ts(value)
end
end,
})
end
---@param item snacks.gh.Item
---@param opts snacks.gh.api.Config
@ -95,14 +116,17 @@ function M:update(data, fields)
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,
})
wrap_ts(comment)
end
for _, review in ipairs(item.reviews or {}) do
review.body = review.body and review.body:gsub("\r\n", "\n") or nil
wrap_ts(review)
for _, comment in ipairs(review.comments or {}) do
comment.body = comment.body and comment.body:gsub("\r\n", "\n") or nil
wrap_ts(comment)
end
end
if item.reactionGroups then
self.reactions = {}
for _, reaction in ipairs(item.reactionGroups) do

View file

@ -0,0 +1,168 @@
---@class snacks.gh.Diff
---@field opts snacks.gh.Config
---@field level number
---@field path string
---@field hunk string
---@field ft string
---@field context number
---@field diff snacks.gh.diff.Line[]
---@field comment snacks.gh.Comment
local M = {}
M.__index = M
---@class snacks.gh.diff.Line
---@field type " "|"+"|"-"
---@field line number
---@field lines string[]
---@field virt_lines snacks.picker.Text[][]
local diff_types = {
[" "] = "SnacksGhDiffContext",
["+"] = "SnacksGhDiffAdd",
["-"] = "SnacksGhDiffDelete",
}
---@param comment snacks.gh.Comment
---@param level number
---@param opts snacks.gh.Config
function M.new(comment, level, opts)
local self = setmetatable({}, M)
self.opts = opts
self.level = level
self.path = comment.path
self.hunk = comment.diffHunk
self.ft = ""
self.comment = comment
self.diff = {}
self:compute()
return self
end
function M:compute()
-- lines and types
local lines = vim.split(self.hunk, "\n", { plain = true })
table.remove(lines, 1) -- remove hunk header
local types = {} ---@type string[]
for l, line in ipairs(lines) do
types[l], lines[l] = line:sub(1, 1), line:sub(2)
end
local count = 1
self.comment.originalLine = self.comment.originalLine or self.comment.line or 1
if self.comment.originalStartLine then
count = self.comment.originalLine - self.comment.originalStartLine + 1
end
self.context = math.min(#lines, math.max(self.opts.diff.min, math.abs(count)))
-- filetype
self.ft = vim.filetype.match({ filename = self.path, contents = lines }) or ""
local virt_lines = Snacks.picker.highlight.get_virtual_lines(table.concat(lines, "\n"), { ft = self.ft })
for l = 1, #lines do
---@type snacks.gh.diff.Line
local line = {
type = types[l],
line = self.comment.originalLine - #lines + l,
lines = { "" },
virt_lines = { {} },
}
self.diff[l] = line
local virt_line = virt_lines[l] or {}
local w = 0
for _, chunk in ipairs(virt_line) do
local chunk_width = vim.api.nvim_strwidth(chunk[1])
-- split chunk if too long
while chunk_width > self.opts.diff.wrap do
local left = vim.fn.strcharpart(chunk[1], 0, self.opts.diff.wrap)
local right = chunk[1]:sub(#left + 1)
vim.list_extend(line.virt_lines, { { { left, chunk[2] } }, {} })
vim.list_extend(line.lines, { left, "" })
w = 0
chunk = { right, chunk[2] }
chunk_width = vim.api.nvim_strwidth(chunk[1])
end
-- wrap line if needed
if w > 0 and w + chunk_width > self.opts.diff.wrap then
w = 0
table.insert(line.virt_lines, {})
table.insert(line.lines, "")
end
w = w + chunk_width
table.insert(line.virt_lines[#line.virt_lines], chunk)
line.lines[#line.lines] = line.lines[#line.lines] .. chunk[1]
end
end
end
-- Plugins like render-markdown or markview.nvim, may interfere with virtual text rendering.
-- To avoid that, we use 'overlay' virt_text position with 'replace' hl_mode,
-- to render the whole diff block.
-- The regular text is still added in a code block, so that it can be copied correctly.
-- The rendered diff, has filetype specific highlights, and line numbers, and diff highlights.
function M:format()
local ret = {} ---@type snacks.picker.Highlight[][]
local offset = math.max(1, #self.diff - self.context + 1)
local indent_extmark = require("snacks.gh.render").indent(self.level)
local indent = indent_extmark.virt_text ---@type snacks.picker.Text[]
local indent_width = Snacks.picker.highlight.offset(indent)
local a = Snacks.picker.util.align
local lino_width = #tostring(self.comment.originalLine) + 2
local hl_header = "SnacksGhDiffHeader"
ret[#ret + 1] = { indent_extmark }
ret[#ret + 1] = { indent_extmark, { a("", self.opts.diff.wrap + 3 + lino_width), hl_header } }
local icon, icon_hl = Snacks.util.icon(self.path)
icon_hl = icon_hl or hl_header
ret[#ret + 1] = {
indent_extmark,
{ " ", hl_header },
{ icon, { hl_header, icon_hl } },
{ " ", hl_header },
{
self.path
.. a("", self.opts.diff.wrap + lino_width - vim.api.nvim_strwidth(self.path) + vim.api.nvim_strwidth(icon) - 3),
"SnacksGhDiffHeader",
},
}
ret[#ret + 1] = { indent_extmark, { a("", self.opts.diff.wrap + 3 + lino_width), hl_header } }
ret[#ret + 1] = { indent_extmark, { "```" } }
for l = offset, #self.diff do
local diff = self.diff[l]
local hl = diff_types[diff.type] or diff_types[" "]
for i, str in ipairs(diff.lines) do
local virt_text = {} ---@type snacks.picker.Text[]
local id = a("", lino_width, { align = "right" })
vim.list_extend(virt_text, indent)
if i == 1 then -- first visual line
table.insert(virt_text, { a(tostring(diff.line) .. " ", lino_width, { align = "right" }), hl .. "LineNr" })
table.insert(virt_text, { " " .. diff.type .. " ", hl })
else -- wrapped line
table.insert(virt_text, { id, hl .. "LineNr" })
table.insert(virt_text, { " ", hl })
end
for _, chunk in ipairs(diff.virt_lines[i] or {}) do
if type(chunk[2]) == "string" then
chunk[2] = { chunk[2], hl }
elseif chunk[2] == nil then
chunk[2] = hl
end
table.insert(virt_text, chunk)
end
table.insert(virt_text, { string.rep(" ", self.opts.diff.wrap - vim.api.nvim_strwidth(str)), hl })
ret[#ret + 1] = {
{ a("", indent_width + lino_width - 1) .. str },
{ virt_text = virt_text, virt_text_pos = "overlay", col = 0, hl_mode = "replace", priority = 200 },
}
end
end
ret[#ret + 1] = { indent_extmark, { "```" } }
return ret
end
return M

View file

@ -3,6 +3,15 @@ local Markdown = require("snacks.picker.util.markdown")
local M = {}
local extend = Snacks.picker.highlight.extend
-- tracking comment_skip is needed because review comments can appear both:
-- 1. As top-level review.comments
-- 2. As replies in the thread tree
---@class snacks.gh.render.ctx
---@field buf number
---@field item snacks.picker.gh.Item
---@field opts snacks.gh.Config
---@field comment_skip table<string, boolean>
---@param field string
local function time_prop(field)
return {
@ -168,7 +177,7 @@ M.props = {
for _, status in ipairs(order) do
local count = stats[status]
if count then
ret[#ret + 1] = { string.rep(opts.icons.block, count), "SnacksGHCheck" .. status }
ret[#ret + 1] = { string.rep(opts.icons.block, count), "SnacksGhCheck" .. status }
end
end
return ret
@ -219,9 +228,9 @@ M.props = {
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" }
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
@ -239,6 +248,14 @@ function M.render(buf, item, opts)
return
end
---@type snacks.gh.render.ctx
local ctx = {
buf = buf,
item = item,
opts = opts,
comment_skip = {},
}
local lines = {} ---@type snacks.picker.Highlight[][]
item.msg = item.title
@ -274,61 +291,21 @@ function M.render(buf, item, opts)
lines[#lines + 1] = { { l } }
end
end
local comments = item.comments or {}
if #comments > 0 then
lines[#lines + 1] = {} -- empty line
local threads = M.get_threads(item)
if #threads > 0 then
lines[#lines + 1] = { { "" } } -- empty line
lines[#lines + 1] = { { "---", "@punctuation.special.markdown" } }
for _, comment in ipairs(comments) do
for _, thread in ipairs(threads) 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 },
}
if thread.submitted then
---@cast thread snacks.gh.Review
vim.list_extend(lines, M.review(thread, 1, ctx))
else
---@cast thread snacks.gh.Comment
vim.list_extend(lines, M.comment(thread, 1, ctx))
end
end
end
@ -384,4 +361,191 @@ function M.render(buf, item, opts)
vim.bo[buf].modifiable = false
end
---@param item snacks.picker.gh.Item
function M.get_threads(item)
local ret = {} ---@type snacks.gh.Thread[]
vim.list_extend(ret, item.comments or {})
for _, review in ipairs(item.reviews or {}) do
local thread = setmetatable({
created = review.submitted,
}, { __index = review }) --[[@as snacks.gh.Thread]]
ret[#ret + 1] = thread
end
table.sort(ret, function(a, b)
return a.created < b.created
end)
return ret
end
---@param comment snacks.gh.Comment|snacks.gh.Review
---@param opts? {text?:string}
---@param ctx snacks.gh.render.ctx
function M.comment_header(comment, opts, ctx)
opts = opts or {}
local ret = {} ---@type snacks.picker.Highlight[]
local is_bot = comment.author.login == "github-actions"
if is_bot then
extend(ret, Snacks.picker.highlight.badge(ctx.opts.icons.logo .. " " .. comment.author.login, "SnacksGhBotBadge"))
else
extend(ret, Snacks.picker.highlight.badge(ctx.opts.icons.user .. " " .. comment.author.login, "SnacksGhUserBadge"))
end
if opts.text then
ret[#ret + 1] = { " " }
ret[#ret + 1] = { opts.text, "SnacksGhCommentAction" }
end
ret[#ret + 1] = { " " }
ret[#ret + 1] = { 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 == ctx.item.author and "Author" or assoc
if assoc then
ret[#ret + 1] = { " " }
extend(
ret,
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
ret[#ret + 1] = { " " }
local badge = Snacks.picker.highlight.badge(
ctx.opts.icons.reactions[r.content:lower()] .. " " .. tostring(r.users.totalCount),
"SnacksGhReactionBadge"
)
extend(ret, badge)
end
return ret
end
---@param body string
---@param level number
---@param ctx snacks.gh.render.ctx
function M.comment_body(body, level, ctx)
if body:match("^%s*$") then
return {}
end
local ret = {} ---@type snacks.picker.Highlight[][]
local indent = M.indent(level)
for _, l in ipairs(vim.split(body, "\n", { plain = true })) do
ret[#ret + 1] = {
indent,
{ l },
}
end
return ret
end
---@param level number
function M.indent(level)
local indent = {} ---@type string[][]
for i = 1, level do
indent[#indent + 1] = { " " }
indent[#indent + 1] = { "", "@punctuation.definition.blockquote.markdown" }
indent[#indent + 1] = { " " }
end
---@type snacks.picker.Extmark
return {
col = 0,
virt_text = indent,
virt_text_pos = "inline",
hl_mode = "combine",
virt_text_repeat_linebreak = true,
}
end
---@param comment snacks.gh.Comment
---@param level number
---@param ctx snacks.gh.render.ctx
function M.comment_diff(comment, level, ctx)
if not comment.path or not comment.diffHunk then
return {}
end
return require("snacks.gh.render.diff").new(comment, level, ctx.opts):format()
end
---@param comment snacks.gh.Comment
---@param level number
---@param ctx snacks.gh.render.ctx
function M.comment(comment, level, ctx)
local ret = {} ---@type snacks.picker.Highlight[][]
local header = { M.indent(level - 1) }
extend(header, M.comment_header(comment, {}, ctx))
ret[#ret + 1] = header
if not comment.replyTo then
-- add diff hunk for top-level comments
vim.list_extend(ret, M.comment_diff(comment, level, ctx))
if #ret > 0 then
ret[#ret + 1] = { M.indent(level) } -- empty line between diff and body
end
end
vim.list_extend(ret, M.comment_body(comment.body or "", level, ctx))
local replies = M.find_reply(comment.id, ctx)
for _, reply in ipairs(replies) do
ret[#ret + 1] = { M.indent(level) } -- empty line between comment and reply
vim.list_extend(ret, M.comment(reply, level, ctx))
ctx.comment_skip[reply.id] = true
end
return ret
end
---@param id string
---@param ctx snacks.gh.render.ctx
function M.find_reply(id, ctx)
local ret = {} ---@type snacks.gh.Comment[]
for _, review in ipairs(ctx.item.reviews or {}) do
for _, comment in ipairs(review.comments or {}) do
if comment.replyTo and comment.replyTo.id == id then
ret[#ret + 1] = comment
end
end
end
return ret
end
---@param review snacks.gh.Review
---@param level number
---@param ctx snacks.gh.render.ctx
function M.review(review, level, ctx)
local ret = {} ---@type snacks.picker.Highlight[][]
---@type snacks.gh.Comment[]
local comments = vim.tbl_filter(function(c)
return not ctx.comment_skip[c.id]
end, review.comments or {})
if #comments == 0 and (not review.body or review.body:match("^%s*$")) then
return ret
end
local header = { M.indent(level - 1) }
local state_icon = ctx.opts.icons.review[review.state:lower()] or ctx.opts.icons.pr.open
extend(
header,
Snacks.picker.highlight.badge(
state_icon,
"SnacksGhReview" .. Snacks.picker.util.title(review.state:lower()):gsub(" ", "")
)
)
header[#header + 1] = { " " }
local texts = {
["CHANGES_REQUESTED"] = "requested changes",
["COMMENTED"] = "reviewed",
}
local text = texts[review.state] or review.state:lower():gsub("_", " ")
extend(header, M.comment_header(review, { text = text }, ctx))
ret[#ret + 1] = header
vim.list_extend(ret, M.comment_body(review.body or "", level, ctx))
for _, comment in ipairs(comments) do
ret[#ret + 1] = { M.indent(level) } -- empty line between review and comments
vim.list_extend(ret, M.comment(comment, level + 1, ctx))
end
return ret
end
return M

View file

@ -26,7 +26,8 @@
---@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 fields? table<string, string|number|boolean> raw fields (--raw-field)
---@field params? table<string, string|number|boolean> typed fields (--field)
---@field header? table<string, string|number|boolean>
---@field jq? string
---@field input? string
@ -36,6 +37,10 @@
---@field slurp? boolean
---@field on_error? fun(proc: snacks.spawn.Proc, err: string)
---@class snacks.gh.api.GraphQL: snacks.gh.api.Api
---@field endpoint? nil -- should be "/graphql"
---@field query string
---@alias snacks.gh.Field {arg:string, prop:string, name:string}
---@class snacks.gh.cli.Action: snacks.gh.api.Cmd
@ -73,7 +78,7 @@
---@field is_bot? boolean
---@class snacks.gh.Check
---@field __typename string
---@field __typename "CheckRun" | "StatusContext"
---@field completedAt? string
---@field conclusion? "SUCCESS" | "FAILURE" | "SKIPPED"
---@field detailsUrl? string
@ -81,6 +86,22 @@
---@field startedAt? string
---@field status "PENDING" | "COMPLETED"
---@field workflowName string
---@field context? string
---@field state? "SUCCESS" | "FAILURE" | "PENDING"
---@class snacks.gh.Review
---@field id string
---@field author snacks.gh.User
---@field authorAssociation string
---@field body string
---@field submittedAt string
---@field submitted number
---@field reactionGroups? snacks.gh.Reaction[]
---@field state "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING"
---@field commit? {oid: string}
---@field comments? snacks.gh.Comment[]
---@alias snacks.gh.Thread snacks.gh.Comment|snacks.gh.Review|{created: number}
---@class snacks.gh.Item
---@field number number
@ -105,6 +126,7 @@
---@field baseRefName? string
---@field headRefName? string
---@field isDraft? boolean
---@field reviews? snacks.gh.Review[]
---@class snacks.gh.Commit
---@field oid string
@ -119,13 +141,20 @@
---@field url string
---@field author { login: string }
---@field authorAssociation? string
---@field includesCreatedEdit boolean
---@field viewerDidAuthor boolean
---@field isMinimized boolean
---@field minimizedReason string
---@field includesCreatedEdit? boolean
---@field viewerDidAuthor? boolean
---@field isMinimized? boolean
---@field minimizedReason? string
---@field body string
---@field createdAt string
---@field reactionGroups? snacks.gh.Reaction[]
---@field created? number
---@field replyTo? {id: string}
---@field path? string
---@field diffHunk? string
---@field line? number
---@field originalLine? number
---@field originalStartLine? number
---@class snacks.picker.gh.Item: snacks.picker.Item,snacks.gh.Item,snacks.picker.finder.Item
---@field type "issue" | "pr"

View file

@ -2,7 +2,7 @@ local M = {}
---@alias snacks.picker.format.resolve fun(max_width:number):snacks.picker.Highlight[]
---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number, field?:string}
---@alias snacks.picker.Text {[1]:string, [2]:string?, virtual?:boolean, field?:string, resolve?:snacks.picker.format.resolve}
---@alias snacks.picker.Text {[1]:string, [2]:(string|string[])?, virtual?:boolean, field?:string, resolve?:snacks.picker.format.resolve}
---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark
---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[]
---@alias snacks.picker.preview fun(ctx: snacks.picker.preview.ctx):boolean?

View file

@ -106,6 +106,71 @@ function M.get_highlights(opts)
return ret
end
---@param source string|number
---@param opts? {ft:string, bg?: string}
---@return snacks.picker.Text[]
function M.get_virtual_lines(source, opts)
opts = opts or {}
local lines = type(source) == "number" and vim.api.nvim_buf_get_lines(source, 0, -1, false)
or vim.split(source --[[@as string]], "\n")
local extmarks = M.get_highlights({
buf = type(source) == "number" and source or nil,
code = type(source) == "string" and source or nil,
ft = opts.ft,
lang = nil,
})
if not extmarks then
return vim.tbl_map(function(line)
return { { line } }
end, lines)
end
local index = {} ---@type table<number, table<number, string>>
for row, exts in pairs(extmarks) do
for _, e in ipairs(exts) do
if e.hl_group and e.end_col then
index[row] = index[row] or {}
for i = e.col + 1, e.end_col do
index[row][i] = e.hl_group
end
end
end
end
local ret = {} ---@type snacks.picker.Text[]
for i = 1, #lines do
local line = lines[i]
local from = 0
local hl_group = nil ---@type string?
---@param to number
local function add(to)
if to >= from then
ret[i] = ret[i] or {}
local text = line:sub(from, to)
local hl = opts.bg and { hl_group or "Normal", opts.bg } or hl_group
if #text > 0 then
table.insert(ret[i], { text, hl })
end
end
from = to + 1
hl_group = nil
end
for col = 1, #line do
local hl = index[i] and index[i][col]
if hl ~= hl_group then
add(col - 1)
hl_group = hl
end
end
add(#line)
end
return ret
end
---@param line snacks.picker.Highlight[]
---@param opts? {char_idx?:boolean}
function M.offset(line, opts)
@ -120,6 +185,8 @@ function M.offset(line, opts)
else
offset = offset + #t[1]
end
elseif t.virt_text_pos == "inline" and t.virt_text then
offset = offset + M.offset(t.virt_text) + (t.col or 0)
end
end
return offset
@ -325,6 +392,7 @@ function M.fix_offset(hl, offset, start_idx)
end
end
end
return hl
end
---@param dst snacks.picker.Highlight[]