From 85b8ec210975aa137af4b7bef1fb7b7098be331a Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Sat, 1 Nov 2025 12:12:06 +0100 Subject: [PATCH] feat(gh): new `gh` (GitHub cli) integration ## Description ## Related Issue(s) ## Screenshots --- lua/snacks/explorer/watch.lua | 4 +- lua/snacks/gh/actions.lua | 688 ++++++++++++++++++++++++++ lua/snacks/gh/api.lua | 317 ++++++++++++ lua/snacks/gh/buf.lua | 243 +++++++++ lua/snacks/gh/init.lua | 171 +++++++ lua/snacks/gh/item.lua | 129 +++++ lua/snacks/gh/render.lua | 378 ++++++++++++++ lua/snacks/gh/types.lua | 148 ++++++ lua/snacks/image/inline.lua | 2 +- lua/snacks/init.lua | 9 + lua/snacks/layout.lua | 7 +- lua/snacks/meta/types.lua | 2 + lua/snacks/picker/actions.lua | 19 +- lua/snacks/picker/config/defaults.lua | 6 +- lua/snacks/picker/config/layouts.lua | 1 + lua/snacks/picker/config/sources.lua | 101 ++++ lua/snacks/picker/core/actions.lua | 1 + lua/snacks/picker/core/picker.lua | 8 + lua/snacks/picker/core/preview.lua | 40 +- lua/snacks/picker/preview.lua | 6 +- lua/snacks/picker/source/gh.lua | 300 +++++++++++ lua/snacks/picker/source/scratch.lua | 4 +- lua/snacks/picker/types.lua | 5 + lua/snacks/picker/util/init.lua | 19 +- lua/snacks/picker/util/markdown.lua | 1 + lua/snacks/picker/util/spinner.lua | 134 +++++ lua/snacks/statuscolumn.lua | 2 + lua/snacks/util/spawn.lua | 42 +- 28 files changed, 2732 insertions(+), 55 deletions(-) create mode 100644 lua/snacks/gh/actions.lua create mode 100644 lua/snacks/gh/api.lua create mode 100644 lua/snacks/gh/buf.lua create mode 100644 lua/snacks/gh/init.lua create mode 100644 lua/snacks/gh/item.lua create mode 100644 lua/snacks/gh/render.lua create mode 100644 lua/snacks/gh/types.lua create mode 100644 lua/snacks/picker/source/gh.lua create mode 100644 lua/snacks/picker/util/spinner.lua diff --git a/lua/snacks/explorer/watch.lua b/lua/snacks/explorer/watch.lua index 6802af9c..e1445e5c 100644 --- a/lua/snacks/explorer/watch.lua +++ b/lua/snacks/explorer/watch.lua @@ -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 diff --git a/lua/snacks/gh/actions.lua b/lua/snacks/gh/actions.lua new file mode 100644 index 00000000..76466a1e --- /dev/null +++ b/lua/snacks/gh/actions.lua @@ -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 + 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 +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 +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 + 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 + for _, f in ipairs(ctx.opts.fields) do + fields[f.name] = f + end + + local values = {} ---@type table + --- 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 = { + "", + 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 diff --git a/lua/snacks/gh/api.lua b/lua/snacks/gh/api.lua new file mode 100644 index 00000000..c970494d --- /dev/null +++ b/lua/snacks/gh/api.lua @@ -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 +local cache = setmetatable({}, { __mode = "v" }) + +---@type table +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 +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 diff --git a/lua/snacks/gh/buf.lua b/lua/snacks/gh/buf.lua new file mode 100644 index 00000000..99691bcf --- /dev/null +++ b/lua/snacks/gh/buf.lua @@ -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 +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 diff --git a/lua/snacks/gh/init.lua b/lua/snacks/gh/init.lua new file mode 100644 index 00000000..c3fc9132 --- /dev/null +++ b/lua/snacks/gh/init.lua @@ -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? + -- stylua: ignore + keys = { + select = { "", "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 diff --git a/lua/snacks/gh/item.lua b/lua/snacks/gh/item.lua new file mode 100644 index 00000000..ec3d4a5b --- /dev/null +++ b/lua/snacks/gh/item.lua @@ -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 +---@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 diff --git a/lua/snacks/gh/render.lua b/lua/snacks/gh/render.lua new file mode 100644 index 00000000..5d2fa304 --- /dev/null +++ b/lua/snacks/gh/render.lua @@ -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 + 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 + 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 diff --git a/lua/snacks/gh/types.lua b/lua/snacks/gh/types.lua new file mode 100644 index 00000000..d3edd9da --- /dev/null +++ b/lua/snacks/gh/types.lua @@ -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 +---@field header? table +---@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 +---@field created number +---@field updated number +---@field closed? number +---@field merged? number +---@field draft? boolean diff --git a/lua/snacks/image/inline.lua b/lua/snacks/image/inline.lua index 3375bd93..12a59ee6 100644 --- a/lua/snacks/image/inline.lua +++ b/lua/snacks/image/inline.lua @@ -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) diff --git a/lua/snacks/init.lua b/lua/snacks/init.lua index fc849497..e2e6767c 100644 --- a/lua/snacks/init.lua +++ b/lua/snacks/init.lua @@ -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 diff --git a/lua/snacks/layout.lua b/lua/snacks/layout.lua index 7f996bb2..2a477f51 100644 --- a/lua/snacks/layout.lua +++ b/lua/snacks/layout.lua @@ -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 diff --git a/lua/snacks/meta/types.lua b/lua/snacks/meta/types.lua index bab203f8..5630904e 100644 --- a/lua/snacks/meta/types.lua +++ b/lua/snacks/meta/types.lua @@ -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|{} diff --git a/lua/snacks/picker/actions.lua b/lua/snacks/picker/actions.lua index b5fe3735..6369b79b 100644 --- a/lua/snacks/picker/actions.lua +++ b/lua/snacks/picker/actions.lua @@ -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() }) diff --git a/lua/snacks/picker/config/defaults.lua b/lua/snacks/picker/config/defaults.lua index 60d1d4e3..9df24712 100644 --- a/lua/snacks/picker/config/defaults.lua +++ b/lua/snacks/picker/config/defaults.lua @@ -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 = { [""] = "cancel", - ["q"] = "close", + ["q"] = "cancel", ["i"] = "focus_input", [""] = "cycle_win", }, diff --git a/lua/snacks/picker/config/layouts.lua b/lua/snacks/picker/config/layouts.lua index 49b0377c..ea519387 100644 --- a/lua/snacks/picker/config/layouts.lua +++ b/lua/snacks/picker/config/layouts.lua @@ -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", diff --git a/lua/snacks/picker/config/sources.lua b/lua/snacks/picker/config/sources.lua index d3ba13dc..387ca350 100644 --- a/lua/snacks/picker/config/sources.lua +++ b/lua/snacks/picker/config/sources.lua @@ -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 = { + [""] = { "gh_browse", mode = { "n", "i" } }, + [""] = { "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 = { + [""] = { "gh_browse", mode = { "n", "i" } }, + [""] = { "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 may be `status`, `log`, `diff`, etc. diff --git a/lua/snacks/picker/core/actions.lua b/lua/snacks/picker/core/actions.lua index feb651ef..f2631de8 100644 --- a/lua/snacks/picker/core/actions.lua +++ b/lua/snacks/picker/core/actions.lua @@ -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) diff --git a/lua/snacks/picker/core/picker.lua b/lua/snacks/picker/core/picker.lua index bcd4d467..22a4d17d 100644 --- a/lua/snacks/picker/core/picker.lua +++ b/lua/snacks/picker/core/picker.lua @@ -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 } diff --git a/lua/snacks/picker/core/preview.lua b/lua/snacks/picker/core/preview.lua index 673f2c39..9453a3a0 100644 --- a/lua/snacks/picker/core/preview.lua +++ b/lua/snacks/picker/core/preview.lua @@ -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) diff --git a/lua/snacks/picker/preview.lua b/lua/snacks/picker/preview.lua index 5549d1db..25f535b5 100644 --- a/lua/snacks/picker/preview.lua +++ b/lua/snacks/picker/preview.lua @@ -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 diff --git a/lua/snacks/picker/source/gh.lua b/lua/snacks/picker/source/gh.lua new file mode 100644 index 00000000..44f0a650 --- /dev/null +++ b/lua/snacks/picker/source/gh.lua @@ -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 + 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 + 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 diff --git a/lua/snacks/picker/source/scratch.lua b/lua/snacks/picker/source/scratch.lua index 0e6f59df..943ea8ce 100644 --- a/lua/snacks/picker/source/scratch.lua +++ b/lua/snacks/picker/source/scratch.lua @@ -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() diff --git a/lua/snacks/picker/types.lua b/lua/snacks/picker/types.lua index b7cbdc0d..ed1c0cee 100644 --- a/lua/snacks/picker/types.lua +++ b/lua/snacks/picker/types.lua @@ -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 diff --git a/lua/snacks/picker/util/init.lua b/lua/snacks/picker/util/init.lua index 63530c76..050ef240 100644 --- a/lua/snacks/picker/util/init.lua +++ b/lua/snacks/picker/util/init.lua @@ -204,10 +204,25 @@ function M.visual() end ---@param str string ----@param data table +---@param data table|table[] ---@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 diff --git a/lua/snacks/picker/util/markdown.lua b/lua/snacks/picker/util/markdown.lua index 0c442315..b234d8cd 100644 --- a/lua/snacks/picker/util/markdown.lua +++ b/lua/snacks/picker/util/markdown.lua @@ -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 diff --git a/lua/snacks/picker/util/spinner.lua b/lua/snacks/picker/util/spinner.lua new file mode 100644 index 00000000..b6ccc4ff --- /dev/null +++ b/lua/snacks/picker/util/spinner.lua @@ -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 diff --git a/lua/snacks/statuscolumn.lua b/lua/snacks/statuscolumn.lua index 1bf81318..c22faff8 100644 --- a/lua/snacks/statuscolumn.lua +++ b/lua/snacks/statuscolumn.lua @@ -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" diff --git a/lua/snacks/util/spawn.lua b/lua/snacks/util/spawn.lua index 3c6e8ffd..4513e1b9 100644 --- a/lua/snacks/util/spawn.lua +++ b/lua/snacks/util/spawn.lua @@ -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 +---@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