diff --git a/README.md b/README.md index cf57f8cb..128f853b 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,21 @@ require("gitlab").setup({ resolved = '✓', -- Symbol to show next to resolved discussions unresolved = '✖', -- Symbol to show next to unresolved discussions }, + info = { -- Show additional fields in the summary pane + enabled = true, + horizontal = false, -- Display metadata to the left of the summary rather than underneath + fields = { -- The fields listed here will be displayed, in whatever order you choose + "author", + "created_at", + "updated_at", + "merge_status", + "draft", + "conflicts", + "assignees", + "branch", + "pipeline", + }, + }, discussion_sign_and_diagnostic = { skip_resolved_discussion = false, skip_old_revision_discussion = true, @@ -206,6 +221,8 @@ require("gitlab").summary() After editing the description or title, you may save your changes via the `settings.popup.perform_action` keybinding. +By default this plugin will also show additional metadata about the MR in a separate pane underneath the description. This can be disabled, and these fields can be reordered or removed. Please see the `settings.info` section of the configuration. + ### Reviewing Diffs The `review` action will open a diff of the changes. You can leave comments using the `create_comment` action. In visual mode, add multiline comments with the `create_multiline_comment` command, and add suggested changes with the `create_comment_suggestion` command. diff --git a/example.lua b/example.lua new file mode 100644 index 00000000..928a2dae --- /dev/null +++ b/example.lua @@ -0,0 +1,36 @@ +local Layout = require("nui.layout") +local Popup = require("nui.popup") + +local opts = { + buf_options = { + filetype = "markdown", + }, + focusable = true, + border = { + style = "rounded", + }, +} + +local title_popup = Popup(opts) +local description_popup = Popup(opts) +local info_popup = Popup(opts) + +local layout = Layout( + { + position = "50%", + relative = "editor", + size = { + width = "95%", + height = "95%", + }, + }, + Layout.Box({ + Layout.Box(title_popup, { size = { height = 3 } }), + Layout.Box({ + Layout.Box(description_popup, { grow = 1 }), + Layout.Box(info_popup, { size = { height = 15 } }), + }, { dir = "col", size = "100%" }), + }, { dir = "col" }) +) + +layout:mount() diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua index b1deb6bb..03ac662d 100644 --- a/lua/gitlab/actions/pipeline.lua +++ b/lua/gitlab/actions/pipeline.lua @@ -20,6 +20,14 @@ local function get_pipeline() return pipeline end +M.get_pipeline_status = function() + local pipeline = get_pipeline() + if pipeline == nil then + return nil + end + return string.format("%s (%s)", state.settings.pipeline[pipeline.status], pipeline.status) +end + -- The function will render the Pipeline state in a popup M.open = function() local pipeline = get_pipeline() @@ -44,7 +52,7 @@ M.open = function() local lines = {} u.switch_can_edit_buf(bufnr, true) - table.insert(lines, string.format("Status: %s (%s)", state.settings.pipeline[pipeline.status], pipeline.status)) + table.insert(lines, "Status: " .. M.get_pipeline_status()) table.insert(lines, "") table.insert(lines, string.format("Last Run: %s", u.time_since(pipeline.created_at))) table.insert(lines, string.format("Url: %s", pipeline.web_url)) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 4c8046c9..0852034a 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -7,6 +7,8 @@ local job = require("gitlab.job") local u = require("gitlab.utils") local state = require("gitlab.state") local miscellaneous = require("gitlab.actions.miscellaneous") +local pipeline = require("gitlab.actions.pipeline") + local M = { layout_visible = false, layout = nil, @@ -15,7 +17,46 @@ local M = { description_bufnr = nil, } --- The function will render the MR description in a popup +local title_popup_settings = { + buf_options = { + filetype = "markdown", + }, + focusable = true, + border = { + style = "rounded", + }, +} + +local details_popup_settings = { + buf_options = { + filetype = "markdown", + }, + focusable = true, + border = { + style = "rounded", + text = { + top = "Details", + }, + }, +} + +local description_popup_settings = { + buf_options = { + filetype = "markdown", + }, + enter = true, + focusable = true, + border = { + style = "rounded", + text = { + top = "Description", + }, + }, +} + +-- The function will render a popup containing the MR title and MR description, and optionally, +-- any additional metadata that the user wants. The title and description are editable and +-- can be changed via the local action keybinding, which also closes the popup M.summary = function() if M.layout_visible then M.layout:unmount() @@ -23,7 +64,11 @@ M.summary = function() return end - local layout, title_popup, description_popup = M.create_layout() + local title = state.INFO.title + local description_lines = M.build_description_lines() + local info_lines = state.settings.info.enabled and M.build_info_lines() or nil + + local layout, title_popup, description_popup, info_popup = M.create_layout(info_lines) M.layout = layout M.layout_buf = layout.bufnr @@ -34,19 +79,16 @@ M.summary = function() M.layout_visible = false end - local currentBuffer = vim.api.nvim_get_current_buf() - local title = state.INFO.title - local description = state.INFO.description - local lines = {} - - for line in description:gmatch("[^\n]+") do - table.insert(lines, line) - table.insert(lines, "") - end - vim.schedule(function() - vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) + vim.api.nvim_buf_set_lines(description_popup.bufnr, 0, -1, false, description_lines) vim.api.nvim_buf_set_lines(title_popup.bufnr, 0, -1, false, { title }) + + if info_popup then + vim.api.nvim_buf_set_lines(info_popup.bufnr, 0, -1, false, info_lines) + vim.api.nvim_set_option_value("modifiable", false, { buf = info_popup.bufnr }) + vim.api.nvim_set_option_value("readonly", false, { buf = info_popup.bufnr }) + end + state.set_popup_keymaps( description_popup, M.edit_summary, @@ -54,9 +96,76 @@ M.summary = function() { cb = exit, action_before_close = true } ) state.set_popup_keymaps(title_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) + + vim.api.nvim_set_current_buf(description_popup.bufnr) end) end +-- Builds a lua list of strings that contain the MR description +M.build_description_lines = function() + local description_lines = {} + + local description = state.INFO.description + for line in description:gmatch("[^\n]+") do + table.insert(description_lines, line) + table.insert(description_lines, "") + end + + return description_lines +end + +-- Builds a lua list of strings that contain metadata about the current MR. Only builds the +-- lines that users include in their state.settings.info.fields list. +M.build_info_lines = function() + local info = state.INFO + local options = { + author = { title = "Author", content = "@" .. info.author.username .. " (" .. info.author.name .. ")" }, + created_at = { title = "Created", content = u.format_to_local(info.created_at, vim.fn.strftime("%z")) }, + updated_at = { title = "Updated", content = u.format_to_local(info.updated_at, vim.fn.strftime("%z")) }, + merge_status = { title = "Status", content = info.detailed_merge_status }, + draft = { title = "Draft", content = (info.draft and "Yes" or "No") }, + conflicts = { title = "Merge Conflicts", content = (info.has_conflicts and "Yes" or "No") }, + assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") }, + branch = { title = "Branch", content = info.source_branch }, + pipeline = { + title = "Pipeline Status:", + content = function() + return pipeline.get_pipeline_status() + end, + }, + } + + local longest_used = "" + for _, v in ipairs(state.settings.info.fields) do + local title = options[v].title + if string.len(title) > string.len(longest_used) then + longest_used = title + end + end + + local function row_offset(row) + local offset = string.len(longest_used) - string.len(row) + return string.rep(" ", offset + 3) + end + + local lines = {} + for _, v in ipairs(state.settings.info.fields) do + local row = options[v] + local line = "* " .. row.title .. row_offset(row.title) + if type(row.content) == "function" then + local content = row.content() + if content ~= nil then + line = line .. row.content() + end + else + line = line .. row.content + end + table.insert(lines, line) + end + + return lines +end + -- This function will PUT the new description to the Go server M.edit_summary = function() local description = u.get_buffer_text(M.description_bufnr) @@ -71,54 +180,52 @@ M.edit_summary = function() end) end -local top_popup = { - buf_options = { - filetype = "markdown", - }, - focusable = true, - border = { - style = "rounded", - text = { - top = "Merge Request", - }, - }, -} - -local bottom_popup = { - buf_options = { - filetype = "markdown", - }, - enter = true, - focusable = true, - border = { - style = "rounded", - }, -} - -M.create_layout = function() - local title_popup = Popup(top_popup) +M.create_layout = function(info_lines) + local title_popup = Popup(title_popup_settings) M.title_bufnr = title_popup.bufnr - local description_popup = Popup(bottom_popup) + local description_popup = Popup(description_popup_settings) M.description_bufnr = description_popup.bufnr + local details_popup - local layout = Layout( - { - position = "50%", - relative = "editor", - size = { - width = "90%", - height = "70%", - }, - }, - Layout.Box({ - Layout.Box(title_popup, { size = { height = 3 } }), - Layout.Box(description_popup, { size = "100%" }), + local internal_layout + if state.settings.info.enabled then + details_popup = Popup(details_popup_settings) + if state.settings.info.horizontal then + local longest_line = u.get_longest_string(info_lines) + print(longest_line) + internal_layout = Layout.Box({ + Layout.Box(title_popup, { size = 3 }), + Layout.Box({ + Layout.Box(details_popup, { size = longest_line + 3 }), + Layout.Box(description_popup, { grow = 1 }), + }, { dir = "row", size = "100%" }), + }, { dir = "col" }) + else + internal_layout = Layout.Box({ + Layout.Box(title_popup, { size = 3 }), + Layout.Box(description_popup, { grow = 1 }), + Layout.Box(details_popup, { size = #info_lines + 3 }), + }, { dir = "col" }) + end + else + internal_layout = Layout.Box({ + Layout.Box(title_popup, { size = 3 }), + Layout.Box(description_popup, { grow = 1 }), }, { dir = "col" }) - ) + end + + local layout = Layout({ + position = "50%", + relative = "editor", + size = { + width = "95%", + height = "95%", + }, + }, internal_layout) layout:mount() - return layout, title_popup, description_popup + return layout, title_popup, description_popup, details_popup end return M diff --git a/lua/gitlab/spec/util_spec.lua b/lua/gitlab/spec/util_spec.lua index 91f3aa38..079eff69 100644 --- a/lua/gitlab/spec/util_spec.lua +++ b/lua/gitlab/spec/util_spec.lua @@ -178,4 +178,38 @@ describe("utils/init.lua", function() assert.are.same(got, want) end) end) + + describe("offset_to_seconds", function() + local tests = { + est = { "-0500", -18000 }, + pst = { "-0800", -28800 }, + gmt = { "+0000", 0 }, + cet = { "+0100", 360 }, + jst = { "+0900", 32400 }, + ist = { "+0530", 19800 }, + art = { "-0300", -10800 }, + aest = { "+1100", 39600 }, + mmt = { "+0630", 23400 }, + } + + for _, val in ipairs(tests) do + local got = u.offset_to_seconds(val[1]) + local want = val[2] + assert.are.same(got, want) + end + end) + + describe("format_to_local", function() + local tests = { + { "2023-10-28T16:25:09.482Z", "-0500", "10/28/2023 at 11:25" }, + { "2016-11-22T1:25:09.482Z", "-0500", "11/21/2016 at 20:25" }, + { "2016-11-22T1:25:09.482Z", "-0000", "11/22/2016 at 01:25" }, + { "2017-3-22T13:25:09.482Z", "+0700", "03/22/2017 at 20:25" }, + } + for _, val in ipairs(tests) do + local got = u.format_to_local(val[1], val[2]) + local want = val[3] + assert.are.same(got, want) + end + end) end) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 53f62bfd..e5edf454 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -34,6 +34,21 @@ M.settings = { resolved = "✓", unresolved = "", }, + info = { + enabled = true, + horizontal = false, + fields = { + "author", + "created_at", + "updated_at", + "merge_status", + "draft", + "conflicts", + "assignees", + "branch", + "pipeline", + }, + }, discussion_sign_and_diagnostic = { skip_resolved_discussion = false, skip_old_revision_discussion = false, diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 9713ec47..cb29a41f 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -151,6 +151,67 @@ M.reverse = function(list) return rev end +---Returns the difference between a time offset and UTC time, in seconds +---@param offset string The offset to compare, e.g. -0500 for EST +---@return number +M.offset_to_seconds = function(offset) + local sign, hours, minutes = offset:match("([%+%-])(%d%d)(%d%d)") + local offset_in_seconds = tonumber(hours) * 3600 + tonumber(minutes) * 60 + if sign == "-" then + offset_in_seconds = -offset_in_seconds + end + return offset_in_seconds +end + +---Converts a UTC timestamp and offset to a human readable datestring +---@param date_string string The time stamp +---@param offset string The offset of the user's local time zone, e.g. -0500 for EST +---@return string +M.format_to_local = function(date_string, offset) + local year, month, day, hour, min, sec, _, tzOffset = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+).(%d+)Z") + local localTime = os.time({ + year = year, + month = month, + day = day, + hour = hour, + min = min, + sec = sec, + tzOffset = tzOffset, + }) + + local localTimestamp = localTime + M.offset_to_seconds(offset) + + return tostring(os.date("%m/%d/%Y at %H:%M", localTimestamp)) +end + +-- Returns a comma separated (human readable) list of values from a list of associative tables +---@param list_of_tables table The list to traverse +---@param key string The key of the values to pull from the tables +---@return string +M.make_readable_list = function(list_of_tables, key) + local res = "" + for i, t in ipairs(list_of_tables) do + res = res .. t[key] + if i < #list_of_tables then + res = res .. ", " + end + end + return res +end + +-- Returns the length of the longest string in a list of strings +---@param list table The list of strings +---@return number +M.get_longest_string = function(list) + local longest = 0 + for _, v in pairs(list) do + if string.len(v) > longest then + longest = string.len(v) + end + end + return longest +end + M.notify = function(msg, lvl) vim.notify("gitlab.nvim: " .. msg, lvl) end