diff --git a/README.md b/README.md index 7c6642d..40716ef 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/50f44eaf-5f99-4cb3 ## Quick Start 1. Install Go -3. Add configuration (see Installation section) -4. Checkout your feature branch: `git checkout feature-branch` -5. Open Neovim -6. Run `:lua require("gitlab").review()` to open the reviewer pane +2. Add configuration (see Installation section) +3. Checkout your feature branch: `git checkout feature-branch` +4. Open Neovim +5. Run `:lua require("gitlab").review()` to open the reviewer pane ## Installation @@ -66,7 +66,7 @@ use { ## Project Configuration -This plugin requires an auth token to connect to Gitlab. The token can be set in the root directory of the project in a `.gitlab.nvim` environment file, or can be set via a shell environment variable called `GITLAB_TOKEN` instead. If both are present, the `.gitlab.nvim` file will take precedence. +This plugin requires an auth token to connect to Gitlab. The token can be set in the root directory of the project in a `.gitlab.nvim` environment file, or can be set via a shell environment variable called `GITLAB_TOKEN` instead. If both are present, the `.gitlab.nvim` file will take precedence. Optionally provide a GITLAB_URL environment variable (or gitlab_url value in the `.gitlab.nvim` file) to connect to a self-hosted Gitlab instance. This is optional, use ONLY for self-hosted instances. @@ -112,6 +112,36 @@ require("gitlab").setup({ resolved = '✓', -- Symbol to show next to resolved discussions unresolved = '✖', -- Symbol to show next to unresolved discussions }, + discussion_sign_and_diagnostic = { + skip_resolved_discussion = false, + skip_old_revision_discussion = true, + }, + discussion_sign = { + -- See :h sign_define for details about sign configuration. + enabled = true, + text = "💬", + linehl = nil, + texthl = nil, + culhl = nil, + numhl = nil, + priority = 20, -- Priority of sign, the lower the number the higher the priority + helper_signs = { + -- For multiline comments the helper signs are used to indicate the whole context + -- Priority of helper signs is lower than the main sign (-1). + enabled = true, + start = "↑", + mid = "|", + ["end"] = "↓", + }, + }, + discussion_diagnostic = { + -- If you want to customize diagnostics for discussions you can make special config + -- for namespace `gitlab_discussion`. See :h vim.diagnostic.config + enabled = true, + severity = vim.diagnostic.severity.INFO, + code = nil, -- see :h diagnostic-structure + display_opts = {}, -- see opts in vim.diagnostic.set + }, pipeline = { created = "", pending = "", @@ -145,7 +175,7 @@ Then open Neovim. To begin, try running the `summary` command or the `review` co ### Summary -The `summary` action will open the MR title and description. +The `summary` action will open the MR title and description. ```lua require("gitlab").summary() @@ -188,6 +218,32 @@ If you'd like to create a note in an MR (like a comment, but not linked to a spe require("gitlab").create_note() ``` +### Discussions signs and diagnostics + +By default when reviewing files you will see signs and diagnostics ( if enabled in configuration ). When cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show`. You can also jump to discussion tree where you can reply, edit or delete discussion. + +```lua +require("gitlab").move_to_discussion_tree_from_diagnostic() +``` + +The `discussion_sign` configuration controls the display of signs for discussions. The `enabled` option turns on/off the signs. `text` sets the sign text. `linehl`, `texthl`, `culhl`, and `numhl` customize the line highlighting, text highlighting, column highlighting, and number highlighting respectively. Keep in mind that these can be overridden by other configuration (for example diffview.nvim highlights). `priority` controls the sign priority order (when multiple signs are placed on the same line, the sign with highest priority is used). The `helper_signs` table configures additional signs for multiline discussions in order to show the whole context. `enabled` turns on/off the helper signs. `start`, `mid`, and `end` set the helper sign text. + +The `discussion_diagnostic` configuration customizes the diagnostic display for discussions. The `enabled` option turns on/off the diagnostics. `severity` sets the diagnostic severity level and should be set to one of `vim.diagnostic.severity.ERROR`, `vim.diagnostic.severity.WARN`, or `vim.diagnostic.severity.INFO`, `vim.diagnostic.severity.HINT`. `code` specifies a diagnostic code. `display_opts` configures the diagnostic display options where you can configure values like (this is dirrectly used as opts in vim.diagnostic.set): + +- `virtual_text` - Show virtual text for diagnostics. +- `underline` - Underline text for diagnostics. + +Diagnostics for discussions use the `gitlab_discussion` namespace. See `:h vim.diagnostic.config` and `:h diagnostic-structure` for more details. + +Signs and diagnostics have common settings in `discussion_sign_and_diagnostics`. This allows customizing if discussions that are resolved or no longer relevant should still display visual indicators in the editor: + +- `skip_resolved_discussion` - Whether to skip showing signs and diagnostics for resolved discussions. Default is `false`, meaning signs and diagnostics will be shown for resolved discussions. +- `skip_old_revision_discussion` - Whether to skip showing signs and diagnostics for discussions on outdated diff revisions. Default is `true`, meaning signs and diagnostics won't be shown for discussions no longer relevant to the current diff. + +#### Limitations + +When checking multiline diagnostic the cursor must be on the "main" line of diagnostic -> where the `discussion_sign.text` is shown otherwise `vim.diagnostic.show` and `jump_to_discussion_tree_from_diagnostic` will not work. + ### Uploading Files To attach a file to an MR description, reply, comment, and so forth use the `settings.popup.perform_linewise_action` keybinding when the the popup is open. This will open a picker that will look in the directory you specify in the `settings.attachment_dir` folder (this must be an absolute path) for files. @@ -249,6 +305,7 @@ vim.keymap.set("n", "glR", gitlab.revoke) vim.keymap.set("n", "glc", gitlab.create_comment) vim.keymap.set("v", "glc", gitlab.create_multiline_comment) vim.keymap.set("v", "glC", gitlab.create_comment_suggestion) +vim.keymap.set("n", "glm", gitlab.move_to_discussion_tree_from_diagnostic) vim.keymap.set("n", "gln", gitlab.create_note) vim.keymap.set("n", "gld", gitlab.toggle_discussions) vim.keymap.set("n", "glaa", gitlab.add_assignee) diff --git a/cmd/git.go b/cmd/git.go index e7156aa..c7065bd 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -33,8 +33,20 @@ func ExtractGitInfo(getProjectRemoteUrl func() (string, error), getCurrentBranch return GitProjectInfo{}, fmt.Errorf("Could not get project Url: %v", err) } - // play with regex at: https://regex101.com/r/P2jSGh/1 - re := regexp.MustCompile(`(?:^git@.+:|^https?:\/\/.+?[^\/:]\/)(.+)\/([^\/]+)\.git$`) + // play with regex at: https://regex101.com/r/P2jSGh/1 + /* + This should match following formats: + namespace: namespace, projectName: dummy-test-repo: + https://gitlab.com/namespace/dummy-test-repo.git + git@gitlab.com:namespace/dummy-test-repo.git + ssh://git@gitlab.com/namespace/dummy-test-repo.git + + namespace: namespace/subnamespace, projectName: dummy-test-repo: + ssh://git@gitlab.com/namespace/subnamespace/dummy-test-repo + https://git@gitlab.com/namespace/subnamespace/dummy-test-repo.git + git@git@gitlab.com:namespace/subnamespace/dummy-test-repo.git + */ + re := regexp.MustCompile(`(?:^https?:\/\/|^ssh:\/\/|^git@)(?:[^\/:]+)[\/:](.*)\/([^\/]+?)(?:\.git)?$`) matches := re.FindStringSubmatch(url) if len(matches) != 3 { return GitProjectInfo{}, fmt.Errorf("Invalid Git URL format: %s", url) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 8be3312..f4661e1 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -146,6 +146,7 @@ M.confirm_create_comment = function(text, range, unlinked) job.run_job("/comment", "POST", body, function(data) u.notify("Comment created!", vim.log.levels.INFO) discussions.add_discussion({ data = data, unlinked = false }) + discussions.refresh_discussion_data() end) end diff --git a/lua/gitlab/actions/discussions.lua b/lua/gitlab/actions/discussions.lua index e24ef90..01ea280 100644 --- a/lua/gitlab/actions/discussions.lua +++ b/lua/gitlab/actions/discussions.lua @@ -13,33 +13,443 @@ local miscellaneous = require("gitlab.actions.miscellaneous") local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%")) local reply_popup = Popup(u.create_popup_state("Reply", "80%", "80%")) +local discussion_sign_name = "gitlab_discussion" +local discussion_helper_sign_start = "gitlab_discussion_helper_start" +local discussion_helper_sign_mid = "gitlab_discussion_helper_mid" +local discussion_helper_sign_end = "gitlab_discussion_helper_end" +local diagnostics_namespace = vim.api.nvim_create_namespace(discussion_sign_name) local M = { layout_visible = false, layout = nil, layout_buf = nil, + ---@type Discussion[] discussions = {}, + ---@type UnlinkedDiscussion[] unlinked_discussions = {}, - linked_section_bufnr = -1, - unlinked_section_bufnr = -1, + linked_section = nil, + unlinked_section = nil, + discussion_tree = nil, } --- Opens the discussion tree, sets the keybindings. It also --- creates the tree for notes (which are not linked to specific lines of code) -M.toggle = function() +---@class Author +---@field id integer +---@field username string +---@field email string +---@field name string +---@field state string +---@field avatar_url string +---@field web_url string + +---@class LinePosition +---@field line_code string +---@field type string + +---@class GitlabLineRange +---@field start LinePosition +---@field end LinePosition + +---@class NotePosition +---@field base_sha string +---@field start_sha string +---@field head_sha string +---@field position_type string +---@field new_path string? +---@field new_line integer? +---@field old_path string? +---@field old_line integer? +---@field line_range GitlabLineRange? + +---@class Note +---@field id integer +---@field type string +---@field body string +---@field attachment string +---@field title string +---@field file_name string +---@field author Author +---@field system boolean +---@field expires_at string? +---@field updated_at string? +---@field created_at string? +---@field noteable_id integer +---@field noteable_type string +---@field commit_id string +---@field position NotePosition +---@field resolvable boolean +---@field resolved boolean +---@field resolved_by Author +---@field resolved_at string? +---@field noteable_iid integer + +---@class UnlinkedNote: Note +---@field position nil + +---@class Discussion +---@field id string +---@field individual_note boolean +---@field notes Note[] + +---@class UnlinkedDiscussion: Discussion +---@field notes UnlinkedNote[] + +---@class DiscussionData +---@field discussions Discussion[] +---@field unlinked_discussions UnlinkedDiscussion[] + +---Load the discussion data, storage them in M.discussions and M.unlinked_discussions and call +---callback with data +---@param callback fun(data: DiscussionData): nil +M.load_discussions = function(callback) + job.run_job("/discussions", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) + M.discussions = data.discussions + M.unlinked_discussions = data.unlinked_discussions + callback(data) + end) +end + +---Parse line code and return old and new line numbers +---@param line_code string gitlab line code -> 588440f66559714280628a4f9799f0c4eb880a4a_10_10 +---@return number? +---@return number? +local function _parse_line_code(line_code) + local line_code_regex = "%w+_(%d+)_(%d+)" + local old_line, new_line = line_code:match(line_code_regex) + return tonumber(old_line), tonumber(new_line) +end + +---Filter all discussions which are relevant for currently visible signs and diagnostscs. +---@return Discussion[]? +M.filter_discussions_for_signs_and_diagnostics = function() + if type(M.discussions) ~= "table" then + return + end + local file = reviewer.get_current_file() + if not file then + return + end + local discussions = {} + for _, discussion in ipairs(M.discussions) do + local first_note = discussion.notes[1] + if + type(first_note.position) == "table" + and (first_note.position.new_path == file or first_note.position.old_path == file) + then + if + --Skip resolved discussions + not ( + state.settings.discussion_sign_and_diagnostic.skip_resolved_discussion + and first_note.resolvable + and first_note.resolved + ) + --Skip discussions from old revisions + and not ( + state.settings.discussion_sign_and_diagnostic.skip_old_revision_discussion + and first_note.position.base_sha ~= state.MR_REVISIONS[1].base_sha + ) + then + table.insert(discussions, discussion) + end + end + end + return discussions +end + +---Refresh the discussion signs for currently loaded file in reviewer For convinience we use same +---string for sign name and sign group ( currently there is only one sign needed) +M.refresh_signs = function() + local diagnostics = M.filter_discussions_for_signs_and_diagnostics() + if diagnostics == nil then + vim.diagnostic.reset(diagnostics_namespace) + return + end + + local new_signs = {} + local old_signs = {} + for _, discussion in ipairs(diagnostics) do + local first_note = discussion.notes[1] + local base_sign = { + name = discussion_sign_name, + group = discussion_sign_name, + priority = state.settings.discussion_sign.priority, + } + local base_helper_sign = { + name = discussion_sign_name, + group = discussion_sign_name, + priority = state.settings.discussion_sign.priority - 1, + } + if first_note.position.line_range ~= nil then + local start_old_line, start_new_line = _parse_line_code(first_note.position.line_range.start.line_code) + local end_old_line, end_new_line = _parse_line_code(first_note.position.line_range["end"].line_code) + local discussion_line, start_line, end_line + if first_note.position.line_range.start.type == "new" then + table.insert( + new_signs, + vim.tbl_deep_extend("force", { + id = first_note.id, + lnum = first_note.position.new_line, + }, base_sign) + ) + discussion_line = first_note.position.new_line + start_line = start_new_line + end_line = end_new_line + elseif first_note.position.line_range.start.type == "old" then + table.insert( + old_signs, + vim.tbl_deep_extend("force", { + id = first_note.id, + lnum = first_note.position.old_line, + }, base_sign) + ) + discussion_line = first_note.position.old_line + start_line = start_old_line + end_line = end_old_line + end + -- Helper signs does not have specific ids currently. + if state.settings.discussion_sign.helper_signs.enabled then + local helper_signs = {} + if start_line > end_line then + start_line, end_line = end_line, start_line + end + for i = start_line, end_line do + if i ~= discussion_line then + local sign_name + if i == start_line then + sign_name = discussion_helper_sign_start + elseif i == end_line then + sign_name = discussion_helper_sign_end + else + sign_name = discussion_helper_sign_mid + end + table.insert( + helper_signs, + vim.tbl_deep_extend("keep", { + name = sign_name, + lnum = i, + }, base_helper_sign) + ) + end + end + if first_note.position.line_range.start.type == "new" then + vim.list_extend(new_signs, helper_signs) + elseif first_note.position.line_range.start.type == "old" then + vim.list_extend(old_signs, helper_signs) + end + end + else + local sign = vim.tbl_deep_extend("force", { + id = first_note.id, + }, base_sign) + if first_note.position.new_line ~= nil then + table.insert(new_signs, vim.tbl_deep_extend("force", { lnum = first_note.position.new_line }, sign)) + end + if first_note.position.old_line ~= nil then + table.insert(old_signs, vim.tbl_deep_extend("force", { lnum = first_note.position.old_line }, sign)) + end + end + end + vim.fn.sign_unplace(discussion_sign_name) + reviewer.place_sign(old_signs, "old") + reviewer.place_sign(new_signs, "new") +end + +---Refresh the diagnostics for the currently reviewed file +M.refresh_diagnostics = function() + -- Keep in mind that diagnostic line numbers use 0-based indexing while line numbers use + -- 1-based indexing + local diagnostics = M.filter_discussions_for_signs_and_diagnostics() + if diagnostics == nil then + vim.diagnostic.reset(diagnostics_namespace) + return + end + + local new_diagnostics = {} + local old_diagnostics = {} + for _, discussion in ipairs(diagnostics) do + local first_note = discussion.notes[1] + local message = "" + for _, note in ipairs(discussion.notes) do + message = message .. M.build_note_header(note) .. "\n" .. note.body .. "\n" + end + + local diagnostic = { + message = message, + col = 0, + severity = state.settings.discussion_diagnostic.severity, + user_data = { discussion_id = discussion.id, header = M.build_note_header(discussion.notes[1]) }, + source = "gitlab", + code = state.settings.discussion_diagnostic.code, + } + if first_note.position.line_range ~= nil then + -- Diagnostics for line range discussions are tricky - you need to set lnum to + -- line number equal to note.position.new_line or note.position.old_line because that is + -- only line where you can trigger the diagnostic show. This also need to be in sinc + -- with the sign placement. + local start_old_line, start_new_line = _parse_line_code(first_note.position.line_range.start.line_code) + local end_old_line, end_new_line = _parse_line_code(first_note.position.line_range["end"].line_code) + if first_note.position.line_range.start.type == "new" then + local new_diagnostic + if first_note.position.new_line == start_new_line then + new_diagnostic = { + lnum = start_new_line - 1, + end_lnum = end_new_line - 1, + } + else + new_diagnostic = { + lnum = end_new_line - 1, + end_lnum = start_new_line - 1, + } + end + new_diagnostic = vim.tbl_deep_extend("force", new_diagnostic, diagnostic) + table.insert(new_diagnostics, new_diagnostic) + elseif first_note.position.line_range.start.type == "old" then + local old_diagnostic + if first_note.position.old_line == start_old_line then + old_diagnostic = { + lnum = start_old_line - 1, + end_lnum = end_old_line - 1, + } + else + old_diagnostic = { + lnum = end_old_line - 1, + end_lnum = start_old_line - 1, + } + end + old_diagnostic = vim.tbl_deep_extend("force", old_diagnostic, diagnostic) + table.insert(old_diagnostics, old_diagnostic) + end + else + -- Diagnostics for single line discussions. + if first_note.position.new_line ~= nil then + local new_diagnostic = { + lnum = first_note.position.new_line - 1, + } + new_diagnostic = vim.tbl_deep_extend("force", new_diagnostic, diagnostic) + table.insert(new_diagnostics, new_diagnostic) + end + if first_note.position.old_line ~= nil then + local old_diagnostic = { + lnum = first_note.position.old_line - 1, + } + old_diagnostic = vim.tbl_deep_extend("force", old_diagnostic, diagnostic) + table.insert(old_diagnostics, old_diagnostic) + end + end + end + + vim.diagnostic.reset(diagnostics_namespace) + reviewer.set_diagnostics( + diagnostics_namespace, + new_diagnostics, + "new", + state.settings.discussion_diagnostic.display_opts + ) + reviewer.set_diagnostics( + diagnostics_namespace, + old_diagnostics, + "old", + state.settings.discussion_diagnostic.display_opts + ) +end + +---Refresh discussion data, discussion signs and diagnostics +M.refresh_discussion_data = function() + M.load_discussions(function() + if state.settings.discussion_sign.enabled then + M.refresh_signs() + end + if state.settings.discussion_diagnostic.enabled then + M.refresh_diagnostics() + end + end) +end + +---Define signs for discussions if not already defined +M.setup_signs = function() + local discussion_sign = state.settings.discussion_sign + local signs = { + [discussion_sign_name] = discussion_sign.text, + [discussion_helper_sign_start] = discussion_sign.helper_signs.start, + [discussion_helper_sign_mid] = discussion_sign.helper_signs.mid, + [discussion_helper_sign_end] = discussion_sign.helper_signs["end"], + } + for sign_name, sign_text in pairs(signs) do + if #vim.fn.sign_getdefined(sign_name) == 0 then + vim.fn.sign_define(sign_name, { + text = sign_text, + linehl = discussion_sign.linehl, + texthl = discussion_sign.texthl, + culhl = discussion_sign.culhl, + numhl = discussion_sign.numhl, + }) + end + end +end + +---Initialize everything for discussions like setup of signs, callbacks for reviewer, etc. +M.initialize_discussions = function() + M.setup_signs() + M.setup_refresh_discussion_data_callback() + M.setup_leave_reviewer_callback() +end + +---Setup callback to refresh discussion data, discussion signs and diagnostics whenever the +---reviewed file changes. +M.setup_refresh_discussion_data_callback = function() + reviewer.set_callback_for_file_changed(M.refresh_discussion_data) +end + +---Clear all signs and diagnostics +M.clear_signs_and_discussions = function() + vim.fn.sign_unplace(discussion_sign_name) + vim.diagnostic.reset(diagnostics_namespace) +end + +---Setup callback to clear signs and diagnostics whenever reviewer is left. +M.setup_leave_reviewer_callback = function() + reviewer.set_callback_for_reviewer_leave(M.clear_signs_and_discussions) +end + +M.refresh_discussion_tree = function() + if M.layout_visible == false then + return + end + + if type(M.discussions) == "table" then + M.rebuild_discussion_tree() + end + if type(M.unlinked_discussions) == "table" then + M.rebuild_unlinked_discussion_tree() + end + + M.switch_can_edit_bufs(true) + M.add_empty_titles({ + { M.linked_section.bufnr, M.discussions, "No Discussions for this MR" }, + { M.unlinked_section.bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, + }) + M.switch_can_edit_bufs(false) +end + +---Opens the discussion tree, sets the keybindings. It also +---creates the tree for notes (which are not linked to specific lines of code) +---@param callback function? +M.toggle = function(callback) if M.layout_visible then M.layout:unmount() M.layout_visible = false + M.discussion_tree = nil + M.linked_section = nil + M.unlinked_section = nil return end local linked_section, unlinked_section, layout = M.create_layout() - M.linked_section_bufnr = linked_section.bufnr - M.unlinked_section_bufnr = unlinked_section.bufnr + M.linked_section = linked_section + M.unlinked_section = unlinked_section - job.run_job("/discussions", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) - if type(data.discussions) ~= "table" and type(data.unlinked_discussions) ~= "table" then - u.notify("No discussions or notes for this MR", vim.log.levels.WARN) + M.load_discussions(function() + if type(M.discussions) ~= "table" and type(M.unlinked_discussions) ~= "table" then + vim.notify("No discussions or notes for this MR", vim.log.levels.WARN) return end @@ -50,27 +460,67 @@ M.toggle = function() M.layout_visible = true M.layout_buf = layout.bufnr state.discussion_buf = layout.bufnr - - M.discussions = data.discussions - M.unlinked_discussions = data.unlinked_discussions - - if type(data.discussions) == "table" then - M.rebuild_discussion_tree() + M.refresh_discussion_tree() + if type(callback) == "function" then + callback() end - if type(data.unlinked_discussions) == "table" then - M.rebuild_unlinked_discussion_tree() - end - - M.switch_can_edit_bufs(true) - M.add_empty_titles({ - { linked_section.bufnr, data.discussions, "No Discussions for this MR" }, - { unlinked_section.bufnr, data.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, - }) - - M.switch_can_edit_bufs(false) end) end +---Move to the discussion tree at the discussion from diagnostic on current line. +M.move_to_discussion_tree = function() + local current_line = vim.api.nvim_win_get_cursor(0)[1] + local diagnostics = vim.diagnostic.get(0, { namespace = diagnostics_namespace, lnum = current_line - 1 }) + + ---Function used to jump to the discussion tree after the menu selection. + local jump_after_menu_selection = function(diagnostic) + ---Function used to jump to the discussion tree after the discussion tree is opened. + local jump_after_tree_opened = function() + -- All diagnostics in `diagnotics_namespace` have diagnostic_id + local discussion_id = diagnostic.user_data.discussion_id + local discussion_node, line_number = M.discussion_tree:get_node("-" .. discussion_id) + if discussion_node == {} or discussion_node == nil then + vim.notify("Discussion not found", vim.log.levels.WARN) + return + end + if not discussion_node:is_expanded() then + for _, child in ipairs(discussion_node:get_child_ids()) do + M.discussion_tree:get_node(child):expand() + end + discussion_node:expand() + end + M.discussion_tree:render() + vim.api.nvim_win_set_cursor(M.linked_section.winid, { line_number, 0 }) + vim.api.nvim_set_current_win(M.linked_section.winid) + end + + if not M.layout_visible then + M.toggle(jump_after_tree_opened) + else + jump_after_tree_opened() + end + end + + if #diagnostics == 0 then + vim.notify("No diagnostics for this line", vim.log.levels.WARN) + return + elseif #diagnostics > 1 then + vim.ui.select(diagnostics, { + prompt = "Choose discussion to jump to", + format_item = function(diagnostic) + return diagnostic.message + end, + }, function(diagnostic) + if not diagnostic then + return + end + jump_after_menu_selection(diagnostic) + end) + else + jump_after_menu_selection(diagnostics[1]) + end +end + -- The reply popup will mount in a window when you trigger it (settings.discussion_tree.reply) when hovering over a node in the discussion tree. M.reply = function(tree) local node = tree:get_node() @@ -127,13 +577,13 @@ M.send_deletion = function(tree, unlinked) M.discussions = u.remove_first_value(M.discussions) M.rebuild_discussion_tree() end + M.switch_can_edit_bufs(true) + M.add_empty_titles({ + { M.linked_section.bufnr, M.discussions, "No Discussions for this MR" }, + { M.unlinked_section.bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, + }) + M.switch_can_edit_bufs(false) end - M.switch_can_edit_bufs(true) - M.add_empty_titles({ - { M.linked_section_bufnr, M.discussions, "No Discussions for this MR" }, - { M.unlinked_section_bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, - }) - M.switch_can_edit_bufs(false) end) end @@ -255,31 +705,31 @@ end M.rebuild_discussion_tree = function() M.switch_can_edit_bufs(true) - vim.api.nvim_buf_set_lines(M.linked_section_bufnr, 0, -1, false, {}) + vim.api.nvim_buf_set_lines(M.linked_section.bufnr, 0, -1, false, {}) local discussion_tree_nodes = M.add_discussions_to_table(M.discussions) - local discussion_tree = NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_section_bufnr }) + local discussion_tree = NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_section.bufnr }) discussion_tree:render() - M.set_tree_keymaps(discussion_tree, M.linked_section_bufnr, false) + M.set_tree_keymaps(discussion_tree, M.linked_section.bufnr, false) M.discussion_tree = discussion_tree M.switch_can_edit_bufs(false) - vim.api.nvim_buf_set_option(M.linked_section_bufnr, "filetype", "gitlab") + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_section.bufnr }) end M.rebuild_unlinked_discussion_tree = function() M.switch_can_edit_bufs(true) - vim.api.nvim_buf_set_lines(M.unlinked_section_bufnr, 0, -1, false, {}) + vim.api.nvim_buf_set_lines(M.unlinked_section.bufnr, 0, -1, false, {}) local unlinked_discussion_tree_nodes = M.add_discussions_to_table(M.unlinked_discussions) - local unlinked_discussion_tree = NuiTree({ nodes = unlinked_discussion_tree_nodes, bufnr = M.unlinked_section_bufnr }) + local unlinked_discussion_tree = NuiTree({ nodes = unlinked_discussion_tree_nodes, bufnr = M.unlinked_section.bufnr }) unlinked_discussion_tree:render() - M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_section_bufnr, true) + M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_section.bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree M.switch_can_edit_bufs(false) - vim.api.nvim_buf_set_option(M.unlinked_section_bufnr, "filetype", "gitlab") + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_section.bufnr }) end M.switch_can_edit_bufs = function(bool) - u.switch_can_edit_buf(M.unlinked_section_bufnr, bool) - u.switch_can_edit_buf(M.linked_section_bufnr, bool) + u.switch_can_edit_buf(M.unlinked_section.bufnr, bool) + u.switch_can_edit_buf(M.linked_section.bufnr, bool) end M.add_discussion = function(arg) @@ -289,8 +739,7 @@ M.add_discussion = function(arg) M.unlinked_discussions = {} end table.insert(M.unlinked_discussions, 1, discussion) - local bufinfo = vim.fn.getbufinfo(M.unlinked_section_bufnr) - if u.table_size(bufinfo) ~= 0 then + if M.unlinked_section ~= nil then M.rebuild_unlinked_discussion_tree() end return @@ -299,8 +748,7 @@ M.add_discussion = function(arg) M.discussions = {} end table.insert(M.discussions, 1, discussion) - local bufinfo = vim.fn.getbufinfo(M.unlinked_section_bufnr) - if u.table_size(bufinfo) ~= 0 then + if M.linked_section ~= nil then M.rebuild_discussion_tree() end end @@ -442,6 +890,13 @@ local attach_uuid = function(str) return { text = str, id = u.uuid() } end +---Build note header from note. +---@param note Note +---@return string +M.build_note_header = function(note) + return "@" .. note.author.username .. " " .. u.format_date(note.created_at) +end + M.build_note_body = function(note, resolve_info) local text_nodes = {} for bodyLine in note.body:gmatch("[^\n]+") do @@ -464,7 +919,7 @@ M.build_note_body = function(note, resolve_info) or state.settings.discussion_tree.unresolved end - local noteHeader = "@" .. note.author.username .. " " .. u.format_date(note.created_at) .. " " .. resolve_symbol + local noteHeader = M.build_note_header(note) .. " " .. resolve_symbol return noteHeader, text_nodes end diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 10d4428..e059a46 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -24,6 +24,7 @@ return { state.merge_settings(args) -- Sets keymaps and other settings from setup function require("gitlab.colors") -- Sets colors reviewer.init() + discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer end, -- Global Actions 🌎 summary = async.sequence({ info }, summary.summary), @@ -36,8 +37,9 @@ return { create_comment = async.sequence({ info, revisions }, comment.create_comment), create_multiline_comment = async.sequence({ info, revisions }, comment.create_multiline_comment), create_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion), + move_to_discussion_tree_from_diagnostic = async.sequence({}, discussions.move_to_discussion_tree), create_note = async.sequence({ info }, comment.create_note), - review = async.sequence({ u.merge(info, { refresh = true }) }, function() + review = async.sequence({ u.merge(info, { refresh = true }), revisions }, function() reviewer.open() end), pipeline = async.sequence({ info }, pipeline.open), diff --git a/lua/gitlab/reviewer/diffview.lua b/lua/gitlab/reviewer/diffview.lua index 6c4ff23..9f9ed82 100644 --- a/lua/gitlab/reviewer/diffview.lua +++ b/lua/gitlab/reviewer/diffview.lua @@ -2,6 +2,7 @@ local u = require("gitlab.utils") local state = require("gitlab.state") local async_ok, async = pcall(require, "diffview.async") +local diffview_lib = require("diffview.lib") local M = { bufnr = nil, @@ -11,6 +12,17 @@ local M = { M.open = function() vim.api.nvim_command(string.format("DiffviewOpen %s", state.INFO.target_branch)) M.tabnr = vim.api.nvim_get_current_tabpage() + local group = vim.api.nvim_create_augroup("gitlab.diffview.autocommand.close", {}) + vim.api.nvim_create_autocmd("User", { + pattern = { "DiffviewViewClosed" }, + group = group, + callback = function() + --Check if our diffview tab was closed + if vim.api.nvim_tabpage_is_valid(M.tabnr) then + M.tabnr = nil + end + end, + }) end M.jump = function(file_name, new_line, old_line) @@ -20,7 +32,7 @@ M.jump = function(file_name, new_line, old_line) end vim.api.nvim_set_current_tabpage(M.tabnr) vim.cmd("DiffviewFocusFiles") - local view = require("diffview.lib").get_current_view() + local view = diffview_lib.get_current_view() if view == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return @@ -66,7 +78,7 @@ M.get_location = function(range) end -- check if we are in the diffview buffer - local view = require("diffview.lib").get_current_view() + local view = diffview_lib.get_current_view() if view == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return @@ -149,4 +161,83 @@ M.get_lines = function(start_line, end_line) return vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) end +---Get currently shown file +M.get_current_file = function() + local view = diffview_lib.get_current_view() + if not view then + return + end + return view.panel.cur_file.path +end + +---Place a sign in currently reviewed file. Use new line for identifing lines after changes, old +---line for identifing lines before changes and both if line was not changed. +---@param signs table table of signs. See :h sign_placelist +---@param type string "new" if diagnostic should be in file after changes else "old" +M.place_sign = function(signs, type) + local view = diffview_lib.get_current_view() + if not view then + return + end + if type == "new" then + for _, sign in ipairs(signs) do + sign.buffer = view.cur_layout.b.file.bufnr + end + elseif type == "old" then + for _, sign in ipairs(signs) do + sign.buffer = view.cur_layout.a.file.bufnr + end + end + vim.fn.sign_placelist(signs) +end + +---Set diagnostics in currently reviewed file. +---@param namespace integer namespace for diagnostics +---@param diagnostics table see :h vim.diagnostic.set +---@param type string "new" if diagnostic should be in file after changes else "old" +---@param opts table? see :h vim.diagnostic.set +M.set_diagnostics = function(namespace, diagnostics, type, opts) + local view = diffview_lib.get_current_view() + if not view then + return + end + if type == "new" and view.cur_layout.b.file.bufnr then + vim.diagnostic.set(namespace, view.cur_layout.b.file.bufnr, diagnostics, opts) + elseif type == "old" and view.cur_layout.a.file.bufnr then + vim.diagnostic.set(namespace, view.cur_layout.a.file.bufnr, diagnostics, opts) + else + vim.notify("Unknown diagnostic type", vim.log.levels.ERROR) + end +end + +---Diffview exposes events which can be used to setup autocommands. +---@param callback fun(opts: table) - for more information about opts see callback in :h nvim_create_autocmd +M.set_callback_for_file_changed = function(callback) + local group = vim.api.nvim_create_augroup("gitlab.diffview.autocommand.file_changed", {}) + vim.api.nvim_create_autocmd("User", { + pattern = { "DiffviewDiffBufWinEnter", "DiffviewViewEnter" }, + group = group, + callback = function(...) + if M.tabnr == vim.api.nvim_get_current_tabpage() then + callback(...) + end + end, + }) +end + +---Diffview exposes events which can be used to setup autocommands. +---@param callback fun(opts: table) - for more information about opts see callback in :h nvim_create_autocmd +M.set_callback_for_reviewer_leave = function(callback) + local group = vim.api.nvim_create_augroup("gitlab.diffview.autocommand.leave", {}) + vim.api.nvim_create_autocmd("User", { + pattern = { "DiffviewViewLeave", "DiffviewViewClosed" }, + group = group, + callback = function(...) + if M.tabnr == vim.api.nvim_get_current_tabpage() then + callback(...) + end + end, + }) +end + return M diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index f2edc29..e1fc4cd 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -36,6 +36,36 @@ M.init = function() M.get_lines = reviewer.get_lines -- Returns the content of the file in the current location in the reviewer window + + M.get_current_file = reviewer.get_current_file + -- Get currently loaded file + + M.place_sign = reviewer.place_sign + -- Places a sign on the line for currently reviewed file. + -- Parameters: + -- • {id} The sign id + -- • {sign} The sign to place + -- • {group} The sign group to place on + -- • {new_line} The line to place the sign on + -- • {old_line} The buffer number to place the sign on + + M.set_callback_for_file_changed = reviewer.set_callback_for_file_changed + -- Call callback whenever the file changes + -- Parameters: + -- • {callback} The callback to call + + M.set_callback_for_reviewer_leave = reviewer.set_callback_for_reviewer_leave + -- Call callback whenever the reviewer is left + -- Parameters: + -- • {callback} The callback to call + + M.set_diagnostics = reviewer.set_diagnostics + -- Set diagnostics for currently reviewed file + -- Parameters: + -- • {namespace} The namespace for diagnostics + -- • {diagnostics} The diagnostics to set + -- • {type} "new" if diagnostic should be in file after changes else "old" + -- • {opts} see opts in :h vim.diagnostic.set end return M diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 96257b3..3ded1b2 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -33,6 +33,36 @@ M.settings = { resolved = "✓", unresolved = "", }, + discussion_sign_and_diagnostic = { + skip_resolved_discussion = false, + skip_old_revision_discussion = false, + }, + discussion_sign = { + -- See :h sign_define for details about sign configuration. + enabled = true, + text = "💬", + linehl = nil, + texthl = nil, + culhl = nil, + numhl = nil, + priority = 20, + helper_signs = { + -- For multiline comments the helper signs are used to indicate the whole context + -- Priority of helper signs is lower than the main sign (-1). + enabled = true, + start = "↑", + mid = "|", + ["end"] = "↓", + }, + }, + discussion_diagnostic = { + -- If you want to customize diagnostics for discussions you can make special config + -- for namespace `gitlab_discussion`. See :h vim.diagnostic.config + enabled = true, + severity = vim.diagnostic.severity.INFO, + code = nil, -- see :h diagnostic-structure + display_opts = {}, -- this is dirrectly used as opts in vim.diagnostic.set, see :h vim.diagnostic.config. + }, pipeline = { created = "", pending = "",