From 9977d004980d4ec73fe6cd874d3223baa333230a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 30 May 2025 08:02:18 +0200 Subject: [PATCH 1/7] fix: don't attempt placing diagnostics on diffview NULL buffer --- lua/gitlab/indicators/diagnostics.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index ccdd9363..bec6e4ff 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -114,6 +114,9 @@ end ---Filter and place the diagnostics for the given buffer. ---@param bufnr number The number of the buffer for placing diagnostics. M.place_diagnostics = function(bufnr) + if bufnr and vim.api.nvim_buf_get_name(bufnr) == "diffview://null" then + return + end if not state.settings.discussion_signs.enabled then return end From e5bd5ac42b3a1bb695658ec91f60f515365dc54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 30 May 2025 08:03:35 +0200 Subject: [PATCH 2/7] docs: mark parameter as optional --- lua/gitlab/actions/discussions/winbar.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 6dcd6011..41e3b74b 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -270,7 +270,7 @@ M.get_ahead_behind = function(ahead, behind) end ---Toggles the current view type (or sets it to `override`) and then updates the view. ----@param override "discussions"|"notes" Defines the view type to select. +---@param override? "discussions"|"notes" Defines the view type to select. M.switch_view_type = function(override) if override then M.current_view_type = override From 24a6659652eab29114d29146dc84bfdb5f106382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 30 May 2025 08:01:08 +0200 Subject: [PATCH 3/7] feat: add first draft of suggestion preview --- doc/gitlab.nvim.txt | 19 +- lua/gitlab/actions/comment.lua | 49 +- lua/gitlab/actions/common.lua | 60 +- lua/gitlab/actions/discussions/init.lua | 129 ++++- lua/gitlab/actions/discussions/tree.lua | 12 + lua/gitlab/actions/draft_notes/init.lua | 1 + lua/gitlab/actions/suggestions.lua | 735 ++++++++++++++++++++++++ lua/gitlab/git.lua | 103 +++- lua/gitlab/indicators/common.lua | 10 + lua/gitlab/indicators/diagnostics.lua | 27 +- lua/gitlab/init.lua | 1 + lua/gitlab/reviewer/init.lua | 33 ++ lua/gitlab/state.lua | 11 + 13 files changed, 1149 insertions(+), 41 deletions(-) create mode 100644 lua/gitlab/actions/suggestions.lua diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index b95b55aa..8741e085 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -227,11 +227,22 @@ you call this function with no values the defaults will be used: toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions refresh_data = "", -- Refresh the data in the view by hitting Gitlab's APIs again print_node = "p", -- Print the current node (for debugging) + edit_suggestion = "se", -- Edit comment with suggestion preview in a new tab + reply_with_suggestion = "sr", -- Reply to comment with a suggestion preview in a new tab + apply_suggestion = "sa", -- Apply the suggestion to the local file with a preview in a new tab + }, + suggestion_preview = { + apply_changes = "ZZ", -- Close suggestion preview tab, and post comment to Gitlab (discarding changes to local file). In "apply mode", accept suggestion, commit changes, then push to remote and resolve thread + discard_changes = "ZQ", -- Close suggestion preview tab and discard changes in local file + attach_file = "ZA", -- Attach a file from the `settings.attachment_dir` + apply_changes_locally = "Zz", -- Only in "apply mode", close suggestion preview tab and write suggestion buffer to local file (no changes posted to Gitlab) + paste_default_suggestion = "glS", -- Paste the default suggestion below the cursor (overrides default "glS" (start review) keybinding for the "Note" buffer) }, reviewer = { disable_all = false, -- Disable all default mappings for the reviewer windows create_comment = "c", -- Create a comment for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line create_suggestion = "s", -- Create a suggestion for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line + create_suggestion_with_preview = "S", -- In a new tab create a suggestion with a diff preview for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line move_to_discussion_tree = "a", -- Jump to the comment in the discussion tree }, }, @@ -584,9 +595,11 @@ emojis that you have responded with. UPLOADING FILES *gitlab.nvim.uploading-files* To attach a file to an MR description, reply, comment, and so forth use the -`keymaps.popup.perform_linewise_action` keybinding when the popup is open. -This will open a picker that will look for files in the directory you specify -in the `settings.attachment_dir` folder (this must be an absolute path). +`keymaps.popup.perform_linewise_action` keybinding when the popup is open (or +the `keymaps.suggestion_preview.attach_file` in the comment buffer of the +suggestion preview). This will open a picker that will look for files in the +directory you specify in the `settings.attachment_dir` folder (this must be an +absolute path). When you have picked the file, it will be added to the current buffer at the current line. diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 086cd1e6..ae16bd4f 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -21,18 +21,32 @@ local M = { comment_popup = nil, } +---Decide if the comment is a draft based on the draft popup field. +---@return boolean|nil is_draft True if the draft popup exists and the string it contains converts to `true`. If the draft popup does not exist, return nil. +local get_draft_value_from_popup = function() + local buf_is_valid = M.draft_popup and M.draft_popup.bufnr and vim.api.nvim_buf_is_valid(M.draft_popup.bufnr) + if buf_is_valid then + return u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr)) + else + return nil + end +end + ---Fires the API that sends the comment data to the Go server, called when you "confirm" creation ---via the M.settings.keymaps.popup.perform_action keybinding ---@param text string comment text ---@param unlinked boolean if true, the comment is not linked to a line ---@param discussion_id string | nil The ID of the discussion to which the reply is responding, nil if not a reply -local confirm_create_comment = function(text, unlinked, discussion_id) +M.confirm_create_comment = function(text, unlinked, discussion_id) if text == nil then u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) return end - local is_draft = M.draft_popup and u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr)) + local is_draft = get_draft_value_from_popup() + if is_draft == nil then + is_draft = state.settings.discussion_tree.draft_mode + end -- Creating a normal reply to a discussion if discussion_id ~= nil and not is_draft then @@ -188,13 +202,13 @@ M.create_comment_layout = function(opts) ---Keybinding for focus on draft section popup.set_popup_keymaps(M.draft_popup, function() local text = u.get_buffer_text(M.comment_popup.bufnr) - confirm_create_comment(text, unlinked, opts.discussion_id) + M.confirm_create_comment(text, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(current_win) end, miscellaneous.toggle_bool, popup.non_editable_popup_opts) ---Keybinding for focus on text section popup.set_popup_keymaps(M.comment_popup, function(text) - confirm_create_comment(text, unlinked, opts.discussion_id) + M.confirm_create_comment(text, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(current_win) end, miscellaneous.attach_file, popup.editable_popup_opts) @@ -295,6 +309,33 @@ M.create_comment_suggestion = function() end) end +--- This function will create a new tab with a suggestion preview for the changed/updated line in +--- the current MR. +M.create_comment_with_suggestion = function() + M.location = Location.new() + if not M.can_create_comment(true) then + u.press_escape() + return + end + + local old_file_name = M.location.reviewer_data.old_file_name ~= "" and M.location.reviewer_data.old_file_name + or M.location.reviewer_data.file_name + local is_new_sha = M.location.reviewer_data.new_sha_focused + + ---@type ShowPreviewOpts + local opts = { + old_file_name = old_file_name, + new_file_name = M.location.reviewer_data.file_name, + start_line = M.location.visual_range.start_line, + end_line = M.location.visual_range.end_line, + is_new_sha = is_new_sha, + revision = is_new_sha and "HEAD" or require("gitlab.state").INFO.target_branch, + note_header = "comment", + comment_type = "new", + } + require("gitlab.actions.suggestions").show_preview(opts) +end + ---Returns true if it's possible to create an Inline Comment ---@param must_be_visual boolean True if current mode must be visual ---@return boolean diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 30140c7c..44386639 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -173,11 +173,32 @@ M.get_note_node = function(tree, node) end end +---Gather all lines from immediate children that aren't note nodes +---@param tree NuiTree +---@return string[] List of individual note lines +M.get_note_lines = function(tree) + local current_node = tree:get_node() + local note_node = M.get_note_node(tree, current_node) + if note_node == nil then + u.notify("Could not get note node", vim.log.levels.ERROR) + return {} + end + local lines = List.new(note_node:get_child_ids()):reduce(function(agg, child_id) + local child_node = tree:get_node(child_id) + if child_node ~= nil and not child_node:has_children() then + local line = tree:get_node(child_id).text + table.insert(agg, line) + end + return agg + end, {}) + return lines +end + ---Takes a node and returns the line where the note is positioned in the new SHA. If ---the line is not in the new SHA, returns nil ---@param node NuiTree.Node ---@return number|nil -local function get_new_line(node) +M.get_new_line = function(node) ---@type GitlabLineRange|nil local range = node.range if range == nil then @@ -253,17 +274,19 @@ end ---@param root_node NuiTree.Node ---@return integer|nil line_number ---@return boolean is_new_sha True if line number refers to NEW SHA +---@return integer|nil end_line M.get_line_number_from_node = function(root_node) if root_node.range then - local line_number, _, is_new_sha = M.get_line_numbers_for_range( + local line_number, end_line, is_new_sha = M.get_line_numbers_for_range( root_node.old_line, root_node.new_line, root_node.range.start.line_code, root_node.range["end"].line_code ) - return line_number, is_new_sha + return line_number, is_new_sha, end_line else - return M.get_line_number(root_node.id) + local start_line, is_new_sha = M.get_line_number(root_node.id) + return start_line, is_new_sha, start_line end end @@ -303,7 +326,7 @@ M.jump_to_file = function(tree) return end vim.cmd.tabnew() - local line_number = get_new_line(root_node) or get_old_line(root_node) + local line_number = M.get_new_line(root_node) or get_old_line(root_node) if line_number == nil or line_number == 0 then line_number = 1 end @@ -319,4 +342,31 @@ M.jump_to_file = function(tree) vim.api.nvim_win_set_cursor(0, { line_number, 0 }) end +---Determine whether commented line has changed since making the comment. +---@param tree NuiTree The current discussion tree instance. +---@param note_node NuiTree.Node The main node of the note containing the note author etc. +---@return boolean line_changed True if any of the notes in the thread is a system note starting with "changed this line". +M.commented_line_has_changed = function(tree, note_node) + local line_changed = List.new(note_node:get_child_ids()):includes(function(child_id) + local child_node = tree:get_node(child_id) + if child_node == nil then + return false + end + + -- Inspect note bodies or recourse to child notes. + if child_node.type == "note_body" then + local line = tree:get_node(child_id).text + if string.match(line, "^changed this line") and note_node.system then + return true + end + elseif child_node.type == "note" and M.commented_line_has_changed(tree, child_node) then + return true + end + + return false + end) + + return line_changed +end + return M diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index eb1d6c4c..375f834a 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -11,7 +11,6 @@ local popup = require("gitlab.popup") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") local common = require("gitlab.actions.common") -local List = require("gitlab.utils.list") local tree_utils = require("gitlab.actions.discussions.tree") local discussions_tree = require("gitlab.actions.discussions.tree") local draft_notes = require("gitlab.actions.draft_notes") @@ -248,12 +247,85 @@ M.reply = function(tree) discussion_id = discussion_id, unlinked = unlinked, reply = true, + -- TODO: use discussion_node.old_file_name for comments on unchanged lines in renamed files file_name = discussion_node.file_name, }) layout:mount() end +---Open a new tab with a suggestion preview. +---@param tree NuiTree The current discussion tree instance. +---@param action "reply"|"edit"|"apply" Reply to the current thread, edit the current comment or apply the suggestion to local file. +M.suggestion_preview = function(tree, action) + local is_draft = M.is_draft_note(tree) + if action == "reply" and is_draft then + u.notify("Gitlab does not support replying to draft notes", vim.log.levels.WARN) + return + end + + local current_node = tree:get_node() + local root_node = common.get_root_node(tree, current_node) + local note_node = common.get_note_node(tree, current_node) + + -- Return early if note info is missing + if root_node == nil or note_node == nil then + u.notify("Couldn't get root node or note node", vim.log.levels.ERROR) + return + end + local note_node_id = tonumber(note_node.is_root and note_node.root_note_id or note_node.id) + if note_node_id == nil then + u.notify("Couldn't get comment id", vim.log.levels.ERROR) + return + end + + -- Return early if comment position is missing + local start_line, is_new_sha, end_line = common.get_line_number_from_node(root_node) + if start_line == nil or end_line == nil then + u.notify("Couldn't get comment range. Can't create suggestion preview", vim.log.levels.ERROR) + return + end + + -- Override reviewer values when local-applying a suggestion that was made on the OLD version + if action == "apply" and not is_new_sha then + local range = end_line - start_line + start_line = common.get_new_line(root_node) + + if start_line == nil then + u.notify("Couldn't get position in new version. Can't create suggestion preview", vim.log.levels.ERROR) + return + end + + end_line = start_line + range + is_new_sha = true + end + + -- Get values for preview depending on whether comment is on OLD or NEW version + local revision + if is_new_sha then + revision = common.commented_line_has_changed(tree, root_node) and root_node.head_sha or "HEAD" + else + revision = root_node.base_sha + end + + ---@type ShowPreviewOpts + local opts = { + old_file_name = root_node.old_file_name, + new_file_name = root_node.file_name, + start_line = start_line, + end_line = end_line, + is_new_sha = is_new_sha, + revision = revision, + note_header = note_node.text, + comment_type = is_draft and "draft" or action, + note_lines = action ~= "reply" and common.get_note_lines(tree) or nil, + root_node_id = root_node.id, + note_node_id = note_node_id, + tree = tree, + } + require("gitlab.actions.suggestions").show_preview(opts) +end + -- This function (settings.keymaps.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment M.delete_comment = function(tree, unlinked) vim.ui.select({ "Confirm", "Cancel" }, { @@ -297,15 +369,7 @@ M.edit_comment = function(tree, unlinked) edit_popup:mount() - -- Gather all lines from immediate children that aren't note nodes - local lines = List.new(note_node:get_child_ids()):reduce(function(agg, child_id) - local child_node = tree:get_node(child_id) - if not child_node:has_children() then - local line = tree:get_node(child_id).text - table.insert(agg, line) - end - return agg - end, {}) + local lines = common.get_note_lines(tree) local currentBuffer = vim.api.nvim_get_current_buf() vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) @@ -330,7 +394,9 @@ M.edit_comment = function(tree, unlinked) end -- This function (settings.keymaps.discussion_tree.toggle_discussion_resolved) will toggle the resolved status of the current discussion and send the change to the Go server -M.toggle_discussion_resolved = function(tree) +---@param tree NuiTree +---@param override boolean|nil If not nil, set resolved to `override` value instead of toggling. +M.toggle_discussion_resolved = function(tree, override) local note = tree:get_node() if note == nil then return @@ -344,9 +410,16 @@ M.toggle_discussion_resolved = function(tree) return end + local resolved + if override ~= nil then + resolved = override + else + resolved = not note.resolved + end + local body = { discussion_id = note.id, - resolved = not note.resolved, + resolved = resolved, } job.run_job("/mr/discussions/resolve", "PUT", body, function(data) @@ -600,6 +673,34 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) nowait = keymaps.discussion_tree.toggle_tree_type_nowait, }) end + + if keymaps.discussion_tree.edit_suggestion then + vim.keymap.set("n", keymaps.discussion_tree.edit_suggestion, function() + if M.is_current_node_note(tree) then + M.suggestion_preview(tree, "edit") + end + end, { buffer = bufnr, desc = "Edit suggestion", nowait = keymaps.discussion_tree.edit_suggestion_nowait }) + end + + if keymaps.discussion_tree.apply_suggestion then + vim.keymap.set("n", keymaps.discussion_tree.apply_suggestion, function() + if M.is_current_node_note(tree) then + M.suggestion_preview(tree, "apply") + end + end, { buffer = bufnr, desc = "Apply suggestion", nowait = keymaps.discussion_tree.apply_suggestion_nowait }) + end + + if keymaps.discussion_tree.reply_with_suggestion then + vim.keymap.set("n", keymaps.discussion_tree.reply_with_suggestion, function() + if M.is_current_node_note(tree) then + M.suggestion_preview(tree, "reply") + end + end, { + buffer = bufnr, + desc = "Reply with suggestion", + nowait = keymaps.discussion_tree.reply_with_suggestion_nowait, + }) + end end if keymaps.discussion_tree.refresh_data then @@ -812,6 +913,10 @@ end ---Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) M.toggle_draft_mode = function() state.settings.discussion_tree.draft_mode = not state.settings.discussion_tree.draft_mode + vim.api.nvim_exec_autocmds("User", { + pattern = "GitlabDraftModeToggled", + data = { draft_mode = state.settings.discussion_tree.draft_mode }, + }) end ---Toggle between sorting by "original comment" (oldest at the top) or "latest reply" (newest at the diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index e4f192ba..35a4816f 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -39,7 +39,10 @@ M.add_discussions_to_table = function(items, unlinked) local resolved = false local root_new_line = nil local root_old_line = nil + local root_head_sha = nil + local root_base_sha = nil local root_url + local system = false for j, note in ipairs(discussion.notes) do if j == 1 then @@ -48,12 +51,15 @@ M.add_discussions_to_table = function(items, unlinked) root_old_file_name = (type(note.position) == "table" and note.position.old_path or nil) root_new_line = (type(note.position) == "table" and note.position.new_line or nil) root_old_line = (type(note.position) == "table" and note.position.old_line or nil) + root_head_sha = (type(note.position) == "table" and note.position.head_sha) + root_base_sha = (type(note.position) == "table" and note.position.base_sha) root_id = discussion.id root_note_id = tostring(note.id) resolvable = note.resolvable resolved = note.resolved root_url = state.INFO.web_url .. "#note_" .. note.id range = (type(note.position) == "table" and note.position.line_range or nil) + system = note.system else -- Otherwise insert it as a child node... local note_node = M.build_note(note) table.insert(discussion_children, note_node) @@ -85,8 +91,11 @@ M.add_discussions_to_table = function(items, unlinked) old_file_name = root_old_file_name, new_line = root_new_line, old_line = root_old_line, + head_sha = root_head_sha, + base_sha = root_base_sha, resolvable = resolvable, resolved = resolved, + system = system, url = root_url, }, body) @@ -310,7 +319,10 @@ M.build_note = function(note, resolve_info) file_name = (type(note.position) == "table" and note.position.new_path), new_line = (type(note.position) == "table" and note.position.new_line), old_line = (type(note.position) == "table" and note.position.old_line), + head_sha = (type(note.position) == "table" and note.position.head_sha), + base_sha = (type(note.position) == "table" and note.position.base_sha), url = state.INFO.web_url .. "#note_" .. note.id, + system = note.system, type = "note", }, text_nodes) diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 9538678e..870ea60b 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -158,6 +158,7 @@ M.build_root_draft_note = function(note) old_file_name = (type(note.position) == "table" and note.position.old_path or nil), new_line = (type(note.position) == "table" and note.position.new_line or nil), old_line = (type(note.position) == "table" and note.position.old_line or nil), + head_sha = (type(note.position) == "table" and note.position.head_sha or nil), resolvable = false, resolved = false, url = state.INFO.web_url .. "#note_" .. note.id, diff --git a/lua/gitlab/actions/suggestions.lua b/lua/gitlab/actions/suggestions.lua new file mode 100644 index 00000000..0cbba441 --- /dev/null +++ b/lua/gitlab/actions/suggestions.lua @@ -0,0 +1,735 @@ +---This module is responsible for previewing changes suggested in comments. +---The data required to make the API calls are drawn from the discussion nodes. + +local git = require("gitlab.git") +local List = require("gitlab.utils.list") +local u = require("gitlab.utils") +local indicators_common = require("gitlab.indicators.common") + +local M = {} + +vim.fn.sign_define("GitlabSuggestion", { + text = "+", + texthl = "WarningMsg", +}) + +local suggestion_namespace = vim.api.nvim_create_namespace("gitlab_suggestion_note") + +---Refresh the diagnostics from LSP in the suggestions buffer if there are any clients that support +---diagnostics. +---@param suggestion_buf integer Number of the buffer with applied suggestions (can be local or scratch). +local refresh_lsp_diagnostics = function(suggestion_buf) + for _, client in ipairs(vim.lsp.get_clients({ bufnr = suggestion_buf })) do + if client:supports_method("textDocument/diagnostic", suggestion_buf) then + vim.lsp.buf_request(suggestion_buf, "textDocument/diagnostic", { + textDocument = vim.lsp.util.make_text_document_params(suggestion_buf), + }) + end + end +end + +---Reset the contents of the suggestion buffer. +---@param bufnr integer The number of the suggestion buffer. +---@param lines string[] Lines of text to put into the buffer. +---@param imply_local boolean True if buffer is local file and should be written. +local set_buffer_lines = function(bufnr, lines, imply_local) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + -- Recompute and re-apply folds (Otherwise folds are messed up when TextChangedI is triggered). + -- TODO: Find out if it's a (Neo)vim bug. + vim.api.nvim_buf_call(bufnr, function() + vim.cmd("normal! zX") + end) + + if imply_local then + vim.api.nvim_buf_call(bufnr, function() + vim.api.nvim_cmd({ cmd = "write", mods = { silent = true } }, {}) + end) + refresh_lsp_diagnostics(bufnr) + end +end + +---Reset suggestion buffer options and keymaps before closing the preview. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@param suggestion_buf integer Suggestion buffer number. +---@param original_lines string[] The list of lines in the original (commented on) version of the file. +---@param original_suggestion_winbar string The original suggestion buffer/window 'winbar'. +---@param suggestion_winid integer Suggestion window number in the preview tab. +local reset_suggestion_buf = function( + imply_local, + suggestion_buf, + original_lines, + original_suggestion_winbar, + suggestion_winid +) + local keymaps = require("gitlab.state").settings.keymaps + set_buffer_lines(suggestion_buf, original_lines, imply_local) + if imply_local then + pcall(vim.api.nvim_buf_del_keymap, suggestion_buf, "n", keymaps.suggestion_preview.discard_changes) + pcall(vim.api.nvim_buf_del_keymap, suggestion_buf, "n", keymaps.suggestion_preview.apply_changes) + vim.api.nvim_set_option_value("winbar", original_suggestion_winbar, { scope = "local", win = suggestion_winid }) + end +end + +---Set keymaps for the suggestion tab buffers. +---@param note_buf integer Number of the note buffer. +---@param original_buf integer Number of the buffer with the original contents of the file. +---@param suggestion_buf integer Number of the buffer with applied suggestions (can be local or scratch). +---@param original_lines string[] The list of lines in the original (commented on) version of the file. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@param default_suggestion_lines string[] The default suggestion lines with backticks. +---@param original_suggestion_winbar string The original suggestion buffer/window 'winbar'. +---@param suggestion_winid integer Suggestion window number in the preview tab. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +local set_keymaps = function( + note_buf, + original_buf, + suggestion_buf, + original_lines, + imply_local, + default_suggestion_lines, + original_suggestion_winbar, + suggestion_winid, + opts +) + local keymaps = require("gitlab.state").settings.keymaps + + for _, bufnr in ipairs({ note_buf, original_buf, suggestion_buf }) do + -- Reset suggestion buffer to original state and close preview tab + if keymaps.suggestion_preview.discard_changes then + vim.keymap.set("n", keymaps.suggestion_preview.discard_changes, function() + if vim.api.nvim_buf_is_valid(note_buf) then + vim.bo[note_buf].modified = false + end + -- Resetting can cause invalid-buffer errors for temporary (non-local) suggestion buffer + if imply_local then + reset_suggestion_buf( + imply_local, + suggestion_buf, + original_lines, + original_suggestion_winbar, + suggestion_winid + ) + end + vim.cmd.tabclose() + end, { + buffer = bufnr, + desc = "Close preview tab discarding changes", + nowait = keymaps.suggestion_preview.discard_changes_nowait, + }) + end + + -- Post suggestion note to the server. + if keymaps.suggestion_preview.apply_changes then + vim.keymap.set("n", keymaps.suggestion_preview.apply_changes, function() + vim.api.nvim_buf_call(note_buf, function() + vim.api.nvim_cmd({ cmd = "write", mods = { silent = true } }, {}) + end) + + local buf_text = u.get_buffer_text(note_buf) + if opts.comment_type == "reply" then + require("gitlab.actions.comment").confirm_create_comment(buf_text, false, opts.root_node_id) + elseif opts.comment_type == "draft" then + require("gitlab.actions.draft_notes").confirm_edit_draft_note(opts.note_node_id, false)(buf_text) + elseif opts.comment_type == "edit" then + require("gitlab.actions.comment").confirm_edit_comment(opts.root_node_id, opts.note_node_id, false)(buf_text) + elseif opts.comment_type == "new" then + require("gitlab.actions.comment").confirm_create_comment(buf_text, false) + elseif opts.comment_type == "apply" then + if not imply_local then + u.notify("Cannot apply temp-file preview to local file.", vim.log.levels.ERROR) + return + end + if git.has_staged_changes() then + u.notify("Cannot commit suggestion when there are staged changes", vim.log.levels.ERROR) + return + end + if + not git.add({ filename = vim.fn.bufname(suggestion_buf) }) + or not git.commit({ commit_message = "Apply 1 suggestion to 1 file" }) + or not git.push() + then + return + end + require("gitlab.actions.discussions").toggle_discussion_resolved(opts.tree, true) + original_lines = vim.api.nvim_buf_get_lines(suggestion_buf, 0, -1, false) + else + -- This should not really happen. + u.notify(string.format("Cannot perform unsupported action `%s`", opts.comment_type), vim.log.levels.ERROR) + end + + reset_suggestion_buf(imply_local, suggestion_buf, original_lines, original_suggestion_winbar, suggestion_winid) + vim.cmd.tabclose() + end, { + buffer = bufnr, + desc = opts.comment_type == "apply" and "Apply suggestion and resolve thread" + or "Post suggestion comment to Gitlab", + nowait = keymaps.suggestion_preview.apply_changes_nowait, + }) + end + + if opts.comment_type == "apply" and keymaps.suggestion_preview.apply_changes_locally then + vim.keymap.set("n", keymaps.suggestion_preview.apply_changes_locally, function() + vim.api.nvim_buf_call(note_buf, function() + vim.api.nvim_cmd({ cmd = "write", mods = { silent = true } }, {}) + end) + if imply_local then + original_lines = vim.api.nvim_buf_get_lines(suggestion_buf, 0, -1, false) + else + u.notify("Cannot apply temp-file preview to local file.", vim.log.levels.ERROR) + return + end + reset_suggestion_buf(imply_local, suggestion_buf, original_lines, original_suggestion_winbar, suggestion_winid) + vim.cmd.tabclose() + end, { + buffer = bufnr, + desc = "Write changes to local file", + nowait = keymaps.suggestion_preview.apply_changes_locally_nowait, + }) + end + + if keymaps.help then + vim.keymap.set("n", keymaps.help, function() + local help = require("gitlab.actions.help") + help.open() + end, { buffer = bufnr, desc = "Open help", nowait = keymaps.help_nowait }) + end + end + + if keymaps.suggestion_preview.paste_default_suggestion then + vim.keymap.set("n", keymaps.suggestion_preview.paste_default_suggestion, function() + vim.api.nvim_put(default_suggestion_lines, "l", true, false) + end, { + buffer = note_buf, + desc = "Paste default suggestion", + nowait = keymaps.suggestion_preview.paste_default_suggestion_nowait, + }) + end + + if keymaps.suggestion_preview.attach_file and opts.comment_type ~= "apply" then + vim.keymap.set("n", keymaps.suggestion_preview.attach_file, function() + require("gitlab.actions.miscellaneous").attach_file() + end, { + buffer = note_buf, + desc = "Attach file", + nowait = keymaps.suggestion_preview.attach_file_nowait, + }) + end +end + +---Replace a range of items in a list with items from another list. +---@param full_text string[] The full list of lines. +---@param start_idx integer The beginning of the range to be replaced. +---@param end_idx integer The end of the range to be replaced. +---@param new_lines string[] The lines of text that should replace the original range. +---@param note_start_linenr number The line number in the note text where the suggesion begins +---@return string[] new_tbl The new list of lines after replacing. +local replace_line_range = function(full_text, start_idx, end_idx, new_lines, note_start_linenr) + if start_idx < 1 then + u.notify( + string.format("Can't apply suggestion at line %d, invalid start of range.", note_start_linenr), + vim.log.levels.ERROR + ) + return full_text + end + -- Copy the original text + local new_tbl = {} + for _, val in ipairs(full_text) do + table.insert(new_tbl, val) + end + -- Remove old lines + for _ = start_idx, end_idx do + table.remove(new_tbl, start_idx) + end + -- Insert new lines + for i, line in ipairs(new_lines) do + table.insert(new_tbl, start_idx + i - 1, line) + end + return new_tbl +end + +---Refresh the signs in the note buffer. +---@param suggestion Suggestion The data for an individual suggestion. +---@param note_buf integer The number of the note buffer. +local refresh_signs = function(suggestion, note_buf) + vim.fn.sign_unplace("gitlab.suggestion") + if suggestion.is_default then + return + end + vim.fn.sign_place( + suggestion.note_start_linenr, + "gitlab.suggestion", + "GitlabSuggestion", + note_buf, + { lnum = suggestion.note_start_linenr } + ) + vim.fn.sign_place( + suggestion.note_end_linenr, + "gitlab.suggestion", + "GitlabSuggestion", + note_buf, + { lnum = suggestion.note_end_linenr } + ) +end + +---Create the name for a temporary file. +---@param revision string The revision of the file for which the comment was made. +---@param node_id string|integer The id of the note node containing the suggestion. +---@param file_name string The name of the commented file. +---@return string buf_name The full name of the new buffer. +---@return integer bufnr The number of the buffer associated with the new name (-1 if buffer doesn't exist). +local get_temp_file_name = function(revision, node_id, file_name) + -- TODO: Come up with a nicer naming convention. + local buf_name = string.format("gitlab::%s/%s::%s", revision, node_id, file_name) + local bufnr = vim.fn.bufnr(buf_name) + return buf_name, bufnr +end + +---Get the text on which the suggestion was created. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +---@return string[]|nil original_lines The list of original lines. +local get_original_lines = function(opts) + local original_head_text = git.get_file_revision({ + file_name = opts.is_new_sha and opts.new_file_name or opts.old_file_name, + revision = opts.revision, + }) + -- If the original revision doesn't contain the file, the branch was possibly rebased, and the + -- original revision could not been found. + if original_head_text == nil then + u.notify( + string.format( + "File `%s` doesn't contain any text in revision `%s` for which comment was made", + opts.old_file_name, + opts.revision + ), + vim.log.levels.WARN + ) + return + end + return vim.fn.split(original_head_text, "\n", true) +end + +---Create the default suggestion lines for given comment range. +---@param original_lines string[] The list of lines in the original (commented on) version of the file. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +---@return string[] suggestion_lines +local get_default_suggestion = function(original_lines, opts) + local backticks = "```" + local selected_lines = { unpack(original_lines, opts.start_line, opts.end_line) } + for _, line in ipairs(selected_lines) do + local match = string.match(line, "^%s*(`+)%s*$") + if match and #match >= #backticks then + backticks = match .. "`" + end + end + local suggestion_lines = { backticks .. "suggestion:-" .. (opts.end_line - opts.start_line) .. "+0" } + vim.list_extend(suggestion_lines, selected_lines) + table.insert(suggestion_lines, backticks) + return suggestion_lines +end + +---Check if buffer already exists and return the number of the tab it's open in. +---@param bufnr integer The buffer number to check. +---@return number|nil tabnr The tabpage number if buffer is already open, or nil. +local get_tabnr_for_buf = function(bufnr) + for _, tabnr in ipairs(vim.api.nvim_list_tabpages()) do + for _, winnr in ipairs(vim.api.nvim_tabpage_list_wins(tabnr)) do + if vim.api.nvim_win_get_buf(winnr) == bufnr then + return tabnr + end + end + end + return nil +end + +---@class Suggestion +---@field start_line_offset number The offset for the start of the suggestion (e.g., "2" in suggestion:-2+3) +---@field end_line_offset number The offset for the end of the suggestion (e.g., "3" in suggestion:-2+3) +---@field note_start_linenr number The line number in the note text where the suggesion begins +---@field note_end_linenr number The line number in the note text where the suggesion ends +---@field lines string[] The text of the suggesion +---@field full_text string[] The full text of the file with the suggesion applied +---@field is_default boolean If true, the "suggestion" is a placeholder for comments without actual suggestions. + +---Create the suggestion list from the note text. +---@param note_lines string[] The content of the comment. +---@param end_line integer The last line number of the comment range. +---@param original_lines string[] Array of original lines. +---@return Suggestion[] suggestions List of suggestion data. +local get_suggestions = function(note_lines, end_line, original_lines) + local suggestions = {} + local in_suggestion = false + local suggestion = {} + local quote + + for i, line in ipairs(note_lines) do + local start_quote = string.match(line, "^%s*(`+)suggestion:%-%d+%+%d+") + local end_quote = string.match(line, "^%s*(`+)%s*$") + if start_quote ~= nil and not in_suggestion then + quote = start_quote + in_suggestion = true + suggestion.start_line_offset, suggestion.end_line_offset = string.match(line, "^%s*`+suggestion:%-(%d+)%+(%d+)") + suggestion.note_start_linenr = i + suggestion.lines = {} + elseif in_suggestion and end_quote and end_quote == quote then + suggestion.note_end_linenr = i + + -- Add the full text with the changes applied to the original text. + local start_line = end_line - suggestion.start_line_offset + local end_line_number = end_line + suggestion.end_line_offset + suggestion.full_text = + replace_line_range(original_lines, start_line, end_line_number, suggestion.lines, suggestion.note_start_linenr) + + table.insert(suggestions, suggestion) + in_suggestion = false + suggestion = {} + elseif in_suggestion then + table.insert(suggestion.lines, line) + end + end + + if #suggestions == 0 then + suggestions = { + { + start_line_offset = 0, + end_line_offset = 0, + note_start_linenr = 1, + note_end_linenr = 1, + lines = {}, + full_text = original_lines, + is_default = true, + }, + } + end + return suggestions +end + +---Return true if the file has uncommitted or unsaved changes. +---@param file_name string Name of file to check. +---@return boolean +local is_modified = function(file_name) + local has_changes = git.has_changes(file_name) + local bufnr = vim.fn.bufnr(file_name, true) + if vim.bo[bufnr].modified or has_changes then + return true + end + return false +end + +---Decide if local file should be used to show suggestion preview. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +local determine_imply_local = function(opts) + local head_differs_from_original = git.file_differs_in_revisions({ + revision_1 = opts.revision, + revision_2 = "HEAD", + old_file_name = opts.old_file_name, + file_name = opts.new_file_name, + }) + -- TODO: Find out if this condition is not too restrictive (comment on unchanged lines could be + -- shown in local file just fine). Ideally, change logic of showing comments on unchanged lines + -- from OLD to NEW version (to enable more local-file diffing). + if not opts.is_new_sha then + u.notify("Comment on old text. Using target-branch version", vim.log.levels.WARN) + elseif head_differs_from_original then + u.notify("Line changed. Using version for which comment was made", vim.log.levels.WARN) + elseif is_modified(opts.new_file_name) then + u.notify("File has unsaved or uncommited changes", vim.log.levels.WARN) + else + return true + end + return false +end + +---Create diagnostics data from suggesions. +---@param suggestions Suggestion[] The list of suggestions data for the current note. +---@return vim.Diagnostic[] diagnostics_data List of diagnostic data for vim.diagnostic.set. +local create_diagnostics = function(suggestions) + local diagnostics_data = {} + for _, suggestion in ipairs(suggestions) do + if not suggestion.is_default then + local diagnostic = { + message = table.concat(suggestion.lines, "\n") .. "\n", + col = 0, + severity = vim.diagnostic.severity.INFO, + source = "gitlab", + code = "gitlab.nvim", + lnum = suggestion.note_start_linenr - 1, + } + table.insert(diagnostics_data, diagnostic) + end + end + return diagnostics_data +end + +---Show diagnostics for suggestions (enables using built-in navigation with `]d` and `[d`). +---@param suggestions Suggestion[] The list of suggestions for which diagnostics should be created. +---@param note_buf integer The number of the note buffer +local refresh_diagnostics = function(suggestions, note_buf) + local diagnostics_data = create_diagnostics(suggestions) + vim.diagnostic.reset(suggestion_namespace, note_buf) + vim.diagnostic.set(suggestion_namespace, note_buf, diagnostics_data, indicators_common.create_display_opts()) +end + +---Get the highlighted text for the edit mode of the suggestion buffer. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@return string +local get_edit_mode = function(imply_local) + if imply_local then + return "%#GitlabLiveMode#Local file" + else + return "%#GitlabDraftMode#Temp file" + end +end + +---Get the highlighted text for the draft mode. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +---@return string +local get_draft_mode = function(opts) + if opts.comment_type == "draft" or opts.comment_type == "edit" then + return "" + end + if require("gitlab.state").settings.discussion_tree.draft_mode then + return "%#GitlabDraftMode#Draft" + else + return "%#GitlabLiveMode#Live" + end +end + +---Update the winbar on top of the suggestion preview windows. +---@param note_winid integer Note window number. +---@param suggestion_winid integer Suggestion window number in the preview tab. +---@param original_winid integer Original text window number in the preview tab. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +local update_winbar = function(note_winid, suggestion_winid, original_winid, imply_local, opts) + if original_winid ~= -1 then + local content = string.format(" %s: %s ", "%#Normal#original", "%#GitlabUserName#" .. opts.revision) + vim.api.nvim_set_option_value("winbar", content, { scope = "local", win = original_winid }) + end + + if suggestion_winid ~= -1 then + local content = string.format(" %s: %s ", "%#Normal#mode", get_edit_mode(imply_local)) + vim.api.nvim_set_option_value("winbar", content, { scope = "local", win = suggestion_winid }) + end + + if note_winid ~= -1 then + local content = string.format( + " %s: %s %s ", + "%#Normal#" .. opts.comment_type, + "%#GitlabUserName#" .. opts.note_header, + get_draft_mode(opts) + ) + vim.api.nvim_set_option_value("winbar", content, { scope = "local", win = note_winid }) + end +end + +---Create autocommands for the note buffer. +---@param note_buf integer Note buffer number. +---@param note_winid integer Note window number. +---@param suggestion_buf integer Suggestion buffer number. +---@param suggestion_winid integer Suggestion window number in the preview tab. +---@param original_winid integer Original text window number in the preview tab. +---@param suggestions Suggestion[] List of suggestion data. +---@param original_lines string[] Array of original lines. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +local create_autocommands = function( + note_buf, + note_winid, + suggestion_buf, + suggestion_winid, + original_winid, + suggestions, + original_lines, + imply_local, + opts +) + local last_line = suggestions[1].note_start_linenr + + ---Update the suggestion buffer if the selected suggestion changes in the Comment buffer. + local update_suggestion_buffer = function() + local current_line = vim.fn.line(".") + if current_line == last_line then + return + end + local suggestion = List.new(suggestions):find(function(sug) + return current_line <= sug.note_end_linenr + end) + if not suggestion or u.get_buffer_text(suggestion_buf) == table.concat(suggestion.full_text, "\n") then + return + end + set_buffer_lines(suggestion_buf, suggestion.full_text, imply_local) + last_line = current_line + refresh_signs(suggestion, note_buf) + end + + -- Create autocommand to update the Suggestion buffer when the cursor moves in the Comment buffer. + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { + buffer = note_buf, + callback = function() + update_suggestion_buffer() + end, + }) + + -- Create autocommand to update suggestions list based on the note buffer content. + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + buffer = note_buf, + callback = function() + local updated_note_lines = vim.api.nvim_buf_get_lines(note_buf, 0, -1, false) + suggestions = get_suggestions(updated_note_lines, opts.end_line, original_lines) + last_line = 0 + update_suggestion_buffer() + refresh_diagnostics(suggestions, note_buf) + end, + }) + + -- Update the note buffer header when draft mode is toggled. + local group = vim.api.nvim_create_augroup("GitlabDraftModeToggled" .. note_buf, { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "GitlabDraftModeToggled", + callback = function() + update_winbar(note_winid, suggestion_winid, original_winid, imply_local, opts) + end, + }) + -- Auto-delete the group when the buffer is unloaded. + vim.api.nvim_create_autocmd("BufUnload", { + buffer = note_buf, + group = group, + callback = function() + vim.api.nvim_del_augroup_by_id(group) + end, + }) +end + +---@class ShowPreviewOpts The options passed to the M.show_preview function. +---@field old_file_name string +---@field new_file_name string +---@field start_line integer +---@field end_line integer +---@field is_new_sha boolean +---@field revision string +---@field note_header string +---@field comment_type "apply"|"reply"|"draft"|"edit"|"new" The type of comment ("apply", "reply", "draft" and "edit" come from the discussion tree, "new" from the reviewer) +---@field note_lines string[]|nil +---@field root_node_id string +---@field note_node_id integer +---@field tree NuiTree + +---Get suggestions from the current note and preview them in a new tab. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +M.show_preview = function(opts) + if not git.revision_exists(opts.revision) then + u.notify( + string.format("Revision `%s` for which the comment was made does not exist", opts.revision), + vim.log.levels.ERROR + ) + return + end + + local commented_file_name = opts.is_new_sha and opts.new_file_name or opts.old_file_name + local original_buf_name, original_bufnr = + get_temp_file_name("ORIGINAL", opts.note_node_id or "NEW_COMMENT", commented_file_name) + + -- If preview is already open for given note, go to the tab with a warning. + local tabnr = get_tabnr_for_buf(original_bufnr) + if tabnr ~= nil then + vim.api.nvim_set_current_tabpage(tabnr) + u.notify("Previously created preview can be outdated", vim.log.levels.WARN) + return + end + + local original_lines = get_original_lines(opts) + if original_lines == nil then + return + end + + local note_lines = opts.note_lines or get_default_suggestion(original_lines, opts) + local suggestions = get_suggestions(note_lines, opts.end_line, original_lines) + + -- Create new tab with a temp buffer showing the original version on which the comment was + -- made. + vim.api.nvim_cmd({ cmd = "tabnew", args = { original_buf_name } }, {}) + local original_buf = vim.api.nvim_get_current_buf() + local original_winid = vim.api.nvim_get_current_win() + vim.api.nvim_buf_set_lines(original_buf, 0, -1, false, original_lines) + vim.bo.bufhidden = "wipe" + vim.bo.buflisted = false + vim.bo.buftype = "nofile" + vim.bo.modifiable = false + vim.cmd.filetype("detect") + local buf_filetype = vim.api.nvim_get_option_value("filetype", { buf = 0 }) + + local imply_local = determine_imply_local(opts) + + -- Create the suggestion buffer and show a diff with the original version + local split_cmd = vim.o.columns > 240 and "vsplit" or "split" + if imply_local then + vim.api.nvim_cmd({ cmd = split_cmd, args = { opts.new_file_name } }, {}) + else + local sug_buf_name = get_temp_file_name("SUGGESTION", opts.note_node_id or "NEW_COMMENT", commented_file_name) + vim.api.nvim_cmd({ cmd = split_cmd, args = { sug_buf_name } }, {}) + vim.bo.bufhidden = "wipe" + vim.bo.buflisted = false + vim.bo.buftype = "nofile" + vim.bo.filetype = buf_filetype + end + local suggestion_buf = vim.api.nvim_get_current_buf() + local suggestion_winid = vim.api.nvim_get_current_win() + set_buffer_lines(suggestion_buf, suggestions[1].full_text, imply_local) + vim.cmd("1,2windo diffthis") + + -- Backup the suggestion buffer winbar to reset it when suggestion preview is closed. Despite the + -- option being "window-local", it's carried over to the buffer even after closing the preview. + -- See https://github.com/neovim/neovim/issues/11525 + local suggestion_winbar = vim.api.nvim_get_option_value("winbar", { scope = "local", win = suggestion_winid }) + + -- Create the note window + local note_buf = vim.api.nvim_create_buf(false, false) + local note_winid = vim.fn.win_getid(3) + local note_bufname = vim.fn.tempname() + vim.api.nvim_buf_set_name(note_buf, note_bufname) + vim.api.nvim_cmd({ cmd = "vnew", mods = { split = "botright" }, args = { note_bufname } }, {}) + vim.api.nvim_buf_set_lines(note_buf, 0, -1, false, note_lines) + vim.bo.bufhidden = "wipe" + vim.bo.buflisted = false + vim.bo.filetype = "markdown" + vim.bo.modified = false + + -- Set up keymaps and autocommands + local default_suggestion_lines = get_default_suggestion(original_lines, opts) + set_keymaps( + note_buf, + original_buf, + suggestion_buf, + original_lines, + imply_local, + default_suggestion_lines, + suggestion_winbar, + suggestion_winid, + opts + ) + create_autocommands( + note_buf, + note_winid, + suggestion_buf, + suggestion_winid, + original_winid, + suggestions, + original_lines, + imply_local, + opts + ) + + -- Focus the note window on the first suggestion + vim.api.nvim_win_set_cursor(note_winid, { suggestions[1].note_start_linenr, 0 }) + refresh_signs(suggestions[1], note_buf) + refresh_diagnostics(suggestions, note_buf) + update_winbar(note_winid, suggestion_winid, original_winid, imply_local, opts) +end + +return M diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index ae139df5..82d62c97 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -6,9 +6,12 @@ local M = {} ---@param command table ---@return string|nil, string|nil local run_system = function(command) - local result = vim.fn.trim(vim.fn.system(command)) + -- Preserve trailing newlines when getting contents of file revisions + local result = vim.fn.join(vim.fn.systemlist(command), "\n") if vim.v.shell_error ~= 0 then - require("gitlab.utils").notify(result, vim.log.levels.ERROR) + if result ~= "" then + require("gitlab.utils").notify(result, vim.log.levels.ERROR) + end return nil, result end return result, nil @@ -213,4 +216,100 @@ M.check_mr_in_good_condition = function() end end +---@class GetFileRevisionOpts +---@field revision string The SHA of the revision to get +---@field file_name string The name of the file to get + +---Returns the contents of the file in a given revision +---@param args GetFileRevisionOpts extra arguments for `git show` +---@return string|nil, string|nil +M.get_file_revision = function(args) + if args.revision == nil or args.file_name == nil then + return + end + local object = string.format("%s:%s", args.revision, args.file_name) + return run_system({ "git", "show", object }) +end + +---Returns true if the given revision exists, false otherwise +---@param revision string The revision to check +---@return boolean +M.revision_exists = function(revision) + if revision == nil then + require("gitlab.utils").notify("Invalid nil revision", vim.log.levels.ERROR) + return false + end + local object = string.format("%s", revision) + local result = run_system({ "git", "rev-parse", "--verify", "--quiet", "--end-of-options", object }) + return result ~= nil +end + +---@class FileDiffersInRevisionsOpts +---@field revision_1 string +---@field revision_2 string +---@field old_file_name string +---@field file_name string + +---Returns true if the file differs in two revisions (handles renames) +---@param opts FileDiffersInRevisionsOpts +---@return boolean +M.file_differs_in_revisions = function(opts) + local result = + run_system({ "git", "diff", "-M", opts.revision_1, opts.revision_2, "--", opts.old_file_name, opts.file_name }) + return result ~= "" +end + +---@class AddOpts +---@field filename string The file to stage + +---Returns true if staging succeeds, false otherwise +---@param opts AddOpts +---@return boolean +M.add = function(opts) + local _, add_err = run_system({ "git", "add", opts.filename }) + if add_err ~= nil then + require("gitlab.utils").notify("Adding changes failed: " .. add_err, vim.log.levels.ERROR) + return false + end + return true +end + +---@class CommitOpts +---@field commit_message string The commit message to include in the commit + +---Returns true if the commit succeeds, false otherwise +---@param opts CommitOpts +---@return boolean +M.commit = function(opts) + local _, commit_err = run_system({ "git", "commit", "-m", opts.commit_message, "-q" }) + if commit_err ~= nil then + require("gitlab.utils").notify("Committing changes failed: " .. commit_err, vim.log.levels.ERROR) + return false + end + return true +end + +---Returns true if there are staged changes +---@return boolean +M.has_staged_changes = function() + local result = run_system({ "git", "diff", "--staged" }) + return result ~= "" +end + +---Returns true if the push succeeds, false otherwise +---@return boolean +M.push = function() + local remote_branch = M.get_remote_branch() + if remote_branch == nil then + return false + end + local remote, branch = string.match(remote_branch, "([^/]+)/(.*)") + local _, push_err = run_system({ "git", "push", remote, branch }) + if push_err ~= nil then + require("gitlab.utils").notify("Pushing remote-tracking branch failed: " .. push_err, vim.log.levels.ERROR) + return false + end + return true +end + return M diff --git a/lua/gitlab/indicators/common.lua b/lua/gitlab/indicators/common.lua index 04f68acc..1a42111e 100644 --- a/lua/gitlab/indicators/common.lua +++ b/lua/gitlab/indicators/common.lua @@ -10,6 +10,16 @@ local M = {} ---@field resolved boolean|nil ---@field created_at string|nil +-- Display options for the diagnostic +M.create_display_opts = function() + return { + virtual_text = state.settings.discussion_signs.virtual_text, + severity_sort = true, + underline = false, + signs = state.settings.discussion_signs.use_diagnostic_signs, + } +end + ---Return true if discussion has a placeable diagnostic, false otherwise. ---@param note NoteWithValues ---@return boolean diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index bec6e4ff..602d57d2 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -14,16 +14,6 @@ M.clear_diagnostics = function() vim.diagnostic.reset(diagnostics_namespace) end --- Display options for the diagnostic -local create_display_opts = function() - return { - virtual_text = state.settings.discussion_signs.virtual_text, - severity_sort = true, - underline = false, - signs = state.settings.discussion_signs.use_diagnostic_signs, - } -end - ---Takes some range information and data about a discussion ---and creates a diagnostic to be placed in the reviewer ---@param range_info table @@ -125,9 +115,6 @@ M.place_diagnostics = function(bufnr) u.notify("Could not find Diffview view", vim.log.levels.ERROR) return end - if vim.api.nvim_buf_get_name(bufnr) == "diffview://null" then - return - end local ok, err = pcall(function() local file_discussions = List.new(M.placeable_discussions):filter(function(discussion_or_note) @@ -143,9 +130,19 @@ M.place_diagnostics = function(bufnr) local new_diagnostics, old_diagnostics = List.new(file_discussions):partition(indicators_common.is_new_sha) if bufnr == view.cur_layout.a.file.bufnr then - set_diagnostics(diagnostics_namespace, bufnr, M.parse_diagnostics(old_diagnostics), create_display_opts()) + set_diagnostics( + diagnostics_namespace, + bufnr, + M.parse_diagnostics(old_diagnostics), + indicators_common.create_display_opts() + ) elseif bufnr == view.cur_layout.b.file.bufnr then - set_diagnostics(diagnostics_namespace, bufnr, M.parse_diagnostics(new_diagnostics), create_display_opts()) + set_diagnostics( + diagnostics_namespace, + bufnr, + M.parse_diagnostics(new_diagnostics), + indicators_common.create_display_opts() + ) end end) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index e788a518..38089180 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -77,6 +77,7 @@ 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), + create_comment_with_suggestion = async.sequence({ info, revisions }, comment.create_comment_with_suggestion), move_to_discussion_tree_from_diagnostic = async.sequence({}, discussions.move_to_discussion_tree), create_note = async.sequence({ info }, comment.create_note), create_mr = async.sequence({}, create_mr.start), diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 76d24f32..451b0817 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -431,6 +431,39 @@ M.set_keymaps = function(bufnr) }) end + -- Set mappings for creating suggestions with a preview in a new tab + if keymaps.reviewer.create_suggestion_with_preview ~= false then + -- Set keymap for repeated operator keybinding + vim.keymap.set("o", keymaps.reviewer.create_suggestion_with_preview, function() + -- The "V" in "V%d$" forces linewise motion, see `:h o_V` + vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { string.format("V%d$", vim.v.count1) } }, {}) + end, { + buffer = bufnr, + desc = "Create suggestion with preview for [count] lines", + nowait = keymaps.reviewer.create_suggestion_with_preview_nowait, + }) + + -- Set operator keybinding + vim.keymap.set("n", keymaps.reviewer.create_suggestion_with_preview, function() + M.operator_count = vim.v.count + M.operator = keymaps.reviewer.create_suggestion_with_preview + execute_operatorfunc("create_comment_with_suggestion") + end, { + buffer = bufnr, + desc = "Create suggestion with preview for range of motion", + nowait = keymaps.reviewer.create_suggestion_with_preview_nowait, + }) + + -- Set visual mode keybinding + vim.keymap.set("v", keymaps.reviewer.create_suggestion_with_preview, function() + require("gitlab").create_comment_with_suggestion() + end, { + buffer = bufnr, + desc = "Create suggestion with preview for selected text", + nowait = keymaps.reviewer.create_suggestion_with_preview_nowait, + }) + end + -- Set mapping for moving to discussion tree if keymaps.reviewer.move_to_discussion_tree ~= false then vim.keymap.set("n", keymaps.reviewer.move_to_discussion_tree, function() diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 7a570e8d..54e3c23b 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -130,11 +130,22 @@ M.settings = { toggle_unresolved_discussions = "U", refresh_data = "", print_node = "p", + edit_suggestion = "se", + reply_with_suggestion = "sr", + apply_suggestion = "sa", + }, + suggestion_preview = { + apply_changes = "ZZ", + discard_changes = "ZQ", + attach_file = "ZA", + apply_changes_locally = "Zz", + paste_default_suggestion = "glS", }, reviewer = { disable_all = false, create_comment = "c", create_suggestion = "s", + create_suggestion_with_preview = "S", move_to_discussion_tree = "a", }, }, From 2b1ffda05740483450bcebef9b2bb055a3a869c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 18 Apr 2026 11:22:44 +0200 Subject: [PATCH 4/7] refactor: make function module-level --- lua/gitlab/actions/suggestions.lua | 42 +++++++++++++++--------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/lua/gitlab/actions/suggestions.lua b/lua/gitlab/actions/suggestions.lua index 0cbba441..af9ee0e0 100644 --- a/lua/gitlab/actions/suggestions.lua +++ b/lua/gitlab/actions/suggestions.lua @@ -313,25 +313,6 @@ local get_original_lines = function(opts) return vim.fn.split(original_head_text, "\n", true) end ----Create the default suggestion lines for given comment range. ----@param original_lines string[] The list of lines in the original (commented on) version of the file. ----@param opts ShowPreviewOpts The options passed to the M.show_preview function. ----@return string[] suggestion_lines -local get_default_suggestion = function(original_lines, opts) - local backticks = "```" - local selected_lines = { unpack(original_lines, opts.start_line, opts.end_line) } - for _, line in ipairs(selected_lines) do - local match = string.match(line, "^%s*(`+)%s*$") - if match and #match >= #backticks then - backticks = match .. "`" - end - end - local suggestion_lines = { backticks .. "suggestion:-" .. (opts.end_line - opts.start_line) .. "+0" } - vim.list_extend(suggestion_lines, selected_lines) - table.insert(suggestion_lines, backticks) - return suggestion_lines -end - ---Check if buffer already exists and return the number of the tab it's open in. ---@param bufnr integer The buffer number to check. ---@return number|nil tabnr The tabpage number if buffer is already open, or nil. @@ -648,7 +629,7 @@ M.show_preview = function(opts) return end - local note_lines = opts.note_lines or get_default_suggestion(original_lines, opts) + local note_lines = opts.note_lines or M.build_suggestion(original_lines, opts) local suggestions = get_suggestions(note_lines, opts.end_line, original_lines) -- Create new tab with a temp buffer showing the original version on which the comment was @@ -701,7 +682,7 @@ M.show_preview = function(opts) vim.bo.modified = false -- Set up keymaps and autocommands - local default_suggestion_lines = get_default_suggestion(original_lines, opts) + local default_suggestion_lines = M.build_suggestion(original_lines, opts) set_keymaps( note_buf, original_buf, @@ -732,4 +713,23 @@ M.show_preview = function(opts) update_winbar(note_winid, suggestion_winid, original_winid, imply_local, opts) end +---Create the default suggestion lines for given comment range. +---@param original_lines string[] The list of lines in the original (commented on) version of the file. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +---@return string[] suggestion_lines +M.build_suggestion = function(original_lines, opts) + local backticks = "```" + local selected_lines = { unpack(original_lines, opts.start_line, opts.end_line) } + for _, line in ipairs(selected_lines) do + local match = string.match(line, "^%s*(`+)%s*$") + if match and #match >= #backticks then + backticks = match .. "`" + end + end + local suggestion_lines = { backticks .. "suggestion:-" .. (opts.end_line - opts.start_line) .. "+0" } + vim.list_extend(suggestion_lines, selected_lines) + table.insert(suggestion_lines, backticks) + return suggestion_lines +end + return M From b3dc6745715d8d1e18b491211b5a450212de9283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 18 Apr 2026 11:30:55 +0200 Subject: [PATCH 5/7] refactor: use base types as parameters --- lua/gitlab/actions/suggestions.lua | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lua/gitlab/actions/suggestions.lua b/lua/gitlab/actions/suggestions.lua index af9ee0e0..51b3d6e3 100644 --- a/lua/gitlab/actions/suggestions.lua +++ b/lua/gitlab/actions/suggestions.lua @@ -629,7 +629,7 @@ M.show_preview = function(opts) return end - local note_lines = opts.note_lines or M.build_suggestion(original_lines, opts) + local note_lines = opts.note_lines or M.build_suggestion(original_lines, opts.start_line, opts.end_line) local suggestions = get_suggestions(note_lines, opts.end_line, original_lines) -- Create new tab with a temp buffer showing the original version on which the comment was @@ -682,7 +682,7 @@ M.show_preview = function(opts) vim.bo.modified = false -- Set up keymaps and autocommands - local default_suggestion_lines = M.build_suggestion(original_lines, opts) + local default_suggestion_lines = M.build_suggestion(original_lines, opts.start_line, opts.end_line) set_keymaps( note_buf, original_buf, @@ -715,18 +715,19 @@ end ---Create the default suggestion lines for given comment range. ---@param original_lines string[] The list of lines in the original (commented on) version of the file. ----@param opts ShowPreviewOpts The options passed to the M.show_preview function. +---@param start_line integer The start line number of the commented on selection. +---@param end_line integer The end line number of the commented on selection. ---@return string[] suggestion_lines -M.build_suggestion = function(original_lines, opts) +M.build_suggestion = function(original_lines, start_line, end_line) local backticks = "```" - local selected_lines = { unpack(original_lines, opts.start_line, opts.end_line) } + local selected_lines = { unpack(original_lines, start_line, end_line) } for _, line in ipairs(selected_lines) do local match = string.match(line, "^%s*(`+)%s*$") if match and #match >= #backticks then backticks = match .. "`" end end - local suggestion_lines = { backticks .. "suggestion:-" .. (opts.end_line - opts.start_line) .. "+0" } + local suggestion_lines = { backticks .. "suggestion:-" .. (end_line - start_line) .. "+0" } vim.list_extend(suggestion_lines, selected_lines) table.insert(suggestion_lines, backticks) return suggestion_lines From 517d65cfa2a2e8e20522784bb5af716d85f111f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 18 Apr 2026 14:38:15 +0200 Subject: [PATCH 6/7] refactor: deprecate create_multiline_comment --- cmd/app/comment.go | 4 ++-- doc/gitlab.nvim.txt | 38 ++++++++++++++-------------------- lua/gitlab/actions/comment.lua | 20 ++++-------------- lua/gitlab/init.lua | 5 ++++- lua/gitlab/reviewer/init.lua | 6 +++--- lua/gitlab/utils/init.lua | 2 +- 6 files changed, 29 insertions(+), 46 deletions(-) diff --git a/cmd/app/comment.go b/cmd/app/comment.go index cdd88074..d34bf8b6 100644 --- a/cmd/app/comment.go +++ b/cmd/app/comment.go @@ -42,7 +42,7 @@ type DeleteCommentRequest struct { DiscussionId string `json:"discussion_id" validate:"required"` } -/* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */ +/* deleteComment deletes a note or comment, which are all considered discussion notes. */ func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) { payload := r.Context().Value(payload("payload")).(*DeleteCommentRequest) @@ -81,7 +81,7 @@ func (comment CommentWithPosition) GetPositionData() PositionData { return comment.PositionData } -/* postComment creates a note, multiline comment, or comment. */ +/* postComment creates a note or comment. */ func (a commentService) postComment(w http.ResponseWriter, r *http.Request) { payload := r.Context().Value(payload("payload")).(*PostCommentRequest) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 8741e085..24dd1e27 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -450,18 +450,15 @@ the configuration. REVIEWING AN MR *gitlab.nvim.reviewing-an-mr* 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 +using the `create_comment` action, and add suggestions for changes with the `create_comment_suggestion` command: >lua require("gitlab").review() require("gitlab").create_comment() - require("gitlab").create_multiline_comment() require("gitlab").create_comment_suggestion() < For suggesting changes you can use `create_comment_suggestion` in visual mode -which works similar to `create_multiline_comment` but prefills the comment -window with Gitlab’s suggest changes +which prefills the comment window with Gitlab’s suggest changes code block with prefilled code from the visual selection. Just like the summary, all the different kinds of comments are saved via the @@ -883,24 +880,19 @@ have permission or has not previously approved the MR. *gitlab.nvim.create_comment* gitlab.create_comment() ~ -Opens a popup to create a comment on the current line. Must be called when focused on the -reviewer pane (see the gitlab.nvim.review command), otherwise it will error. +Opens a popup to create a comment on the selected line(s) in the current +buffer. Must be called when focused on the reviewer pane (see the +|gitlab.nvim.review| command). In normal mode comments on the current line. In +visual mode comments on the selected lines >lua require("gitlab").create_comment() - -After the comment is typed, submit it to Gitlab via the -`keymaps.popup.perform_action` keybinding, by default `ZZ`. - - *gitlab.nvim.create_multiline_comment* -gitlab.create_multiline_comment() ~ - -Opens a popup to create a multi-line comment. May only be called in visual -mode, and will use the currently selected lines. ->lua - require("gitlab").create_multiline_comment() - -After the comment is typed, submit it to Gitlab via the -`keymaps.popup.perform_linewise_action` keybinding, by default `ZA`. +< +You can use the `keymaps.popup.perform_linewise_action` keybinding (by default +`ZA`) to attach a file to the comment. After the comment is typed, submit it +to Gitlab via the `keymaps.popup.perform_action` keybinding, by default `ZZ`. +Discard the comment with `keymaps.popup.discard_changes` (`ZQ`), otherwise if +you close the popup with something like `q`, the comment contents are +saved to the temporary register(s) (|gitlab.nvim.temp-registers|). *gitlab.nvim.create_comment_suggestion* gitlab.create_comment_suggestion() ~ @@ -908,8 +900,8 @@ gitlab.create_comment_suggestion() ~ Opens a popup to create a comment suggestion (aka a comment that makes a committable change suggestion to the currently selected lines). >lua - require("gitlab").create_multiline_comment() - + require("gitlab").create_comment_suggestion() +< After the comment is typed, submit it to Gitlab via the `keymaps.popup.perform_linewise_action` keybinding, by default `ZA`. diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index ae16bd4f..a79550f0 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -220,8 +220,9 @@ M.create_comment_layout = function(opts) return layout end ---- This function will open a comment popup in order to create a comment on the changed/updated ---- line in the current MR +--- Creates a comment on the selected line(s) in the current buffer. +--- In normal mode comments on the current line. +--- In visual mode comments on the whole selection. M.create_comment = function() M.location = Location.new() if not M.can_create_comment(false) then @@ -232,19 +233,6 @@ M.create_comment = function() layout:mount() end ---- This function will open a multi-line comment popup in order to create a multi-line comment ---- on the changed/updated line in the current MR -M.create_multiline_comment = function() - M.location = Location.new() - if not M.can_create_comment(true) then - u.press_escape() - return - end - - local layout = M.create_comment_layout({ unlinked = false }) - layout:mount() -end - --- This function will open a a popup to create a "note" (e.g. unlinked comment) --- on the changed/updated line in the current MR M.create_note = function() @@ -394,7 +382,7 @@ M.can_create_comment = function(must_be_visual) return false end - -- Check we're in visual mode for code suggestions and multiline comments + -- Check we're in visual mode for code suggestions if must_be_visual and not u.check_visual_mode() then return false end diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 38089180..9c914fd4 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -75,7 +75,10 @@ return { add_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.add_assignee), delete_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.delete_assignee), create_comment = async.sequence({ info, revisions }, comment.create_comment), - create_multiline_comment = async.sequence({ info, revisions }, comment.create_multiline_comment), + create_multiline_comment = async.sequence({ info, revisions }, function() + u.notify("create_multiline_comment() is deprecated, use create_comment()", vim.log.levels.WARN) + comment.create_comment() + end), create_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion), create_comment_with_suggestion = async.sequence({ info, revisions }, comment.create_comment_with_suggestion), move_to_discussion_tree_from_diagnostic = async.sequence({}, discussions.move_to_discussion_tree), diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 451b0817..cef60385 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -344,7 +344,7 @@ end ---Set the operatorfunc that will work on the lines defined by the motion that follows after the ---operator mapping, and enter the operator-pending mode. ----@param cb string Name of the gitlab.nvim API function to call, e.g., "create_multiline_comment". +---@param cb string Name of the gitlab.nvim API function to call, e.g., "create_comment". local function execute_operatorfunc(cb) M.old_opfunc = vim.opt.operatorfunc M.old_winnr = vim.api.nvim_get_current_win() @@ -385,12 +385,12 @@ M.set_keymaps = function(bufnr) keymaps.reviewer.create_comment, function() M.operator_count = vim.v.count - execute_operatorfunc("create_multiline_comment") + execute_operatorfunc("create_comment") end, { buffer = bufnr, desc = "Create comment for range of motion", nowait = keymaps.reviewer.create_comment_nowait } ) vim.keymap.set("v", keymaps.reviewer.create_comment, function() - require("gitlab").create_multiline_comment() + require("gitlab").create_comment() end, { buffer = bufnr, desc = "Create comment for selected text", diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 0b239630..a54b7d74 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -588,7 +588,7 @@ end M.check_visual_mode = function() local mode = vim.api.nvim_get_mode().mode if mode ~= "v" and mode ~= "V" then - M.notify("Code suggestions and multiline comments are only available in visual mode", vim.log.levels.ERROR) + M.notify("Code suggestions are only available in visual mode", vim.log.levels.ERROR) return false end return true From a5a137bc2886c66dcaddb67bd9c1670d6268d39f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 18 Apr 2026 17:02:52 +0200 Subject: [PATCH 7/7] refactor: deprecate create_comment_suggestion --- README.md | 1 + doc/gitlab.nvim.txt | 56 ++++++++++++------------- lua/gitlab/actions/comment.lua | 75 ++++++++-------------------------- lua/gitlab/annotations.lua | 3 ++ lua/gitlab/init.lua | 8 +++- lua/gitlab/reviewer/init.lua | 21 ++++++---- 6 files changed, 67 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 4b12b1fe..5d285947 100644 --- a/README.md +++ b/README.md @@ -204,5 +204,6 @@ These `keymaps` are active in the reviewer window (the diff view). ``` c Create a comment for the lines that the following {motion} moves over s Create a suggestion for the lines that the following {motion} moves over +S Create a suggestion with preview in a new tab for the lines that the following {motion} moves over a Jump to the comment in the discussion tree ``` diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 24dd1e27..366b64b5 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -449,20 +449,19 @@ the configuration. REVIEWING AN MR *gitlab.nvim.reviewing-an-mr* -The `review` action will open a diff of the changes. You can leave comments -using the `create_comment` action, and add suggestions for changes with the -`create_comment_suggestion` command: +The `review` action opens a diff of the changes (see |gitlab.nvim.review|). +Alternatively, use `choose_merge_request` for more flexibility in choosing the +MR (see |gitlab.nvim.choose_merge_request|). >lua require("gitlab").review() - require("gitlab").create_comment() - require("gitlab").create_comment_suggestion() + require("gitlab").choose_merge_request({ labels = {"include_mrs_with_label"} }) < -For suggesting changes you can use `create_comment_suggestion` in visual mode -which prefills the comment window with Gitlab’s suggest changes - -code block with prefilled code from the visual selection. Just like the -summary, all the different kinds of comments are saved via the -`keymaps.popup.perform_action` keybinding. +You can leave comments in the reviewer windows using the `keymaps.reviewer` +keybindings which work in normal mode as operators and in visual mode they +create comments on the selected lines. Alternatively, you can use the +`create_comment` action in your custom mappings (see +|gitlab.nvim.create_comment|). See `settings.keymaps.popup` for the +keybindings available in the popup windows. DRAFT NOTES *gitlab.nvim.draft-comments* @@ -883,27 +882,26 @@ gitlab.create_comment() ~ Opens a popup to create a comment on the selected line(s) in the current buffer. Must be called when focused on the reviewer pane (see the |gitlab.nvim.review| command). In normal mode comments on the current line. In -visual mode comments on the selected lines +visual mode comments on the selected lines: >lua require("gitlab").create_comment() + require("gitlab").create_comment({ with_suggestion = true }) < -You can use the `keymaps.popup.perform_linewise_action` keybinding (by default -`ZA`) to attach a file to the comment. After the comment is typed, submit it -to Gitlab via the `keymaps.popup.perform_action` keybinding, by default `ZZ`. -Discard the comment with `keymaps.popup.discard_changes` (`ZQ`), otherwise if -you close the popup with something like `q`, the comment contents are -saved to the temporary register(s) (|gitlab.nvim.temp-registers|). - - *gitlab.nvim.create_comment_suggestion* -gitlab.create_comment_suggestion() ~ - -Opens a popup to create a comment suggestion (aka a comment that makes a committable -change suggestion to the currently selected lines). ->lua - require("gitlab").create_comment_suggestion() -< -After the comment is typed, submit it to Gitlab via the -`keymaps.popup.perform_linewise_action` keybinding, by default `ZA`. + Parameters: ~ + • {opts}: (table|nil) Keyword arguments that can be used to include + a suggestion in the comment. + • {with_suggestion} (boolean) When true, pastes into the comment + buffer Gitlab’s suggestion code block prefilled with the + original text from the visual selection. See + https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html. + +In the popup, you can use the `keymaps.popup.perform_linewise_action` +keybinding (by default `ZA`) to attach a file to the comment. After the +comment is typed, submit it to Gitlab via the `keymaps.popup.perform_action` +keybinding, by default `ZZ`. Discard the comment with +`keymaps.popup.discard_changes` (`ZQ`), otherwise if you close the popup with +something like `q`, the comment contents are saved to the temporary +register(s) (|gitlab.nvim.temp-registers|). *gitlab.nvim.create_mr* gitlab.create_mr({opts}) ~ diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index a79550f0..906699de 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -223,14 +223,30 @@ end --- Creates a comment on the selected line(s) in the current buffer. --- In normal mode comments on the current line. --- In visual mode comments on the whole selection. -M.create_comment = function() +---@param opts CreateCommentsOpts? +M.create_comment = function(opts) + opts = opts or {} M.location = Location.new() if not M.can_create_comment(false) then return end + local suggestion_lines = require("gitlab.actions.suggestions").build_suggestion( + vim.api.nvim_buf_get_lines(0, 0, -1, false), + M.location.visual_range.start_line, + M.location.visual_range.end_line + ) + local layout = M.create_comment_layout({ unlinked = false }) layout:mount() + + if opts.with_suggestion then + vim.schedule(function() + if suggestion_lines then + vim.api.nvim_buf_set_lines(M.comment_popup.bufnr, 0, -1, false, suggestion_lines) + end + end) + end end --- This function will open a a popup to create a "note" (e.g. unlinked comment) @@ -240,63 +256,6 @@ M.create_note = function() layout:mount() end ----Given the current visually selected area of text, builds text to fill in the ----comment popup with a suggested change ----@return LineRange|nil -local build_suggestion = function() - local current_line = vim.api.nvim_win_get_cursor(0)[1] - local range_length = M.location.visual_range.end_line - M.location.visual_range.start_line - local backticks = "```" - local selected_lines = u.get_lines(M.location.visual_range.start_line, M.location.visual_range.end_line) - - for _, line in ipairs(selected_lines) do - if string.match(line, "^```%S*$") then - backticks = "````" - break - end - end - - local suggestion_start - if M.location.visual_range.start_line == current_line then - suggestion_start = backticks .. "suggestion:-0+" .. range_length - elseif M.location.visual_range.end_line == current_line then - suggestion_start = backticks .. "suggestion:-" .. range_length .. "+0" - else - --- This should never happen afaik - u.notify("Unexpected suggestion position", vim.log.levels.ERROR) - return nil - end - suggestion_start = suggestion_start - local suggestion_lines = {} - table.insert(suggestion_lines, suggestion_start) - vim.list_extend(suggestion_lines, selected_lines) - table.insert(suggestion_lines, backticks) - - return suggestion_lines -end - ---- This function will open a a popup to create a suggestion comment ---- on the changed/updated line in the current MR ---- See: https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html -M.create_comment_suggestion = function() - M.location = Location.new() - if not M.can_create_comment(true) then - u.press_escape() - return - end - - local suggestion_lines = build_suggestion() - - local layout = M.create_comment_layout({ unlinked = false }) - layout:mount() - - vim.schedule(function() - if suggestion_lines then - vim.api.nvim_buf_set_lines(M.comment_popup.bufnr, 0, -1, false, suggestion_lines) - end - end) -end - --- This function will create a new tab with a suggestion preview for the changed/updated line in --- the current MR. M.create_comment_with_suggestion = function() diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 4b8dfa6c..41f12e1b 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -143,6 +143,9 @@ ---@field commit_id string -- This will always be "" ---@field line_code string ---@field position NotePosition + +---@class CreateCommentsOpts Options for the create_comment function. +---@field with_suggestion boolean When true, paste the default suggestion into the comment buffer. --- --- --- Plugin Settings diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 9c914fd4..d2b1ca1d 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -79,7 +79,13 @@ return { u.notify("create_multiline_comment() is deprecated, use create_comment()", vim.log.levels.WARN) comment.create_comment() end), - create_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion), + create_comment_suggestion = async.sequence({ info, revisions }, function() + u.notify( + "create_comment_suggestion() is deprecated, use create_comment({with_suggestion=true})", + vim.log.levels.WARN + ) + comment.create_comment({ with_suggestion = true }) + end), create_comment_with_suggestion = async.sequence({ info, revisions }, comment.create_comment_with_suggestion), move_to_discussion_tree_from_diagnostic = async.sequence({}, discussions.move_to_discussion_tree), create_note = async.sequence({ info }, comment.create_note), diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index cef60385..43a3e5fa 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -328,12 +328,14 @@ end ---@param callback string Name of the gitlab.nvim API function to call M.execute_callback = function(callback) return function() + local opts = M.callback_opts + M.callback_opts = nil vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { "'[V']" } }, {}) - local _, err = pcall( - vim.api.nvim_cmd, - { cmd = "lua", args = { ("require'gitlab'.%s()"):format(callback) }, mods = { lockmarks = true } }, - {} - ) + local _, err = pcall(vim.api.nvim_cmd, { + cmd = "lua", + args = { ("require'gitlab'.%s(%s)"):format(callback, vim.inspect(opts or {})) }, + mods = { lockmarks = true }, + }, {}) vim.api.nvim_win_set_cursor(M.old_winnr, M.old_cursor_position) vim.opt.operatorfunc = M.old_opfunc if err ~= "" then @@ -345,10 +347,12 @@ end ---Set the operatorfunc that will work on the lines defined by the motion that follows after the ---operator mapping, and enter the operator-pending mode. ---@param cb string Name of the gitlab.nvim API function to call, e.g., "create_comment". -local function execute_operatorfunc(cb) +---@param opts table? Optional arguments for the callback. +local function execute_operatorfunc(cb, opts) M.old_opfunc = vim.opt.operatorfunc M.old_winnr = vim.api.nvim_get_current_win() M.old_cursor_position = vim.api.nvim_win_get_cursor(M.old_winnr) + M.callback_opts = opts vim.opt.operatorfunc = ("v:lua.require'gitlab.reviewer'.execute_callback'%s'"):format(cb) -- Use the operator count before motion to allow, e.g., 2cc == c2c local count = M.operator_count > 0 and tostring(M.operator_count) or "" @@ -413,8 +417,7 @@ M.set_keymaps = function(bufnr) -- Set operator keybinding vim.keymap.set("n", keymaps.reviewer.create_suggestion, function() M.operator_count = vim.v.count - M.operator = keymaps.reviewer.create_suggestion - execute_operatorfunc("create_comment_suggestion") + execute_operatorfunc("create_comment", { with_suggestion = true }) end, { buffer = bufnr, desc = "Create suggestion for range of motion", @@ -423,7 +426,7 @@ M.set_keymaps = function(bufnr) -- Set visual mode keybinding vim.keymap.set("v", keymaps.reviewer.create_suggestion, function() - require("gitlab").create_comment_suggestion() + require("gitlab").create_comment({ with_suggestion = true }) end, { buffer = bufnr, desc = "Create suggestion for selected text",