mirror of
https://github.com/folke/snacks.nvim
synced 2025-12-23 08:47:57 +00:00
feat(gh): added reviews and nice diffs to gh buffer views. See #2411
This commit is contained in:
parent
6f60105302
commit
1335ca1956
8 changed files with 735 additions and 107 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
168
lua/snacks/gh/render/diff.lua
Normal file
168
lua/snacks/gh/render/diff.lua
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue