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/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 b95b55aa..366b64b5 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 }, }, @@ -438,23 +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. In visual mode, add multiline comments with -the `create_multiline_comment` command, and add suggested 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_multiline_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 works similar to `create_multiline_comment` but 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* @@ -584,9 +591,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. @@ -870,35 +879,29 @@ 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`. - - *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_multiline_comment() - -After the comment is typed, submit it to Gitlab via the -`keymaps.popup.perform_linewise_action` keybinding, by default `ZA`. + require("gitlab").create_comment({ with_suggestion = true }) +< + 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 086cd1e6..906699de 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) @@ -206,29 +220,33 @@ 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 -M.create_comment = function() +--- 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. +---@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() -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 + 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 - - 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) @@ -238,61 +256,31 @@ 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() +--- 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 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) + 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 @@ -353,7 +341,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/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/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 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..51b3d6e3 --- /dev/null +++ b/lua/gitlab/actions/suggestions.lua @@ -0,0 +1,736 @@ +---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 + +---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 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 + -- 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 = M.build_suggestion(original_lines, opts.start_line, opts.end_line) + 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 + +---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 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, start_line, end_line) + local backticks = "```" + 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:-" .. (end_line - start_line) .. "+0" } + vim.list_extend(suggestion_lines, selected_lines) + table.insert(suggestion_lines, backticks) + return suggestion_lines +end + +return M 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/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 ccdd9363..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 @@ -114,6 +104,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 @@ -122,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) @@ -140,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..d2b1ca1d 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -75,8 +75,18 @@ 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_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion), + 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 }, 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), create_mr = async.sequence({}, create_mr.start), diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 76d24f32..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 @@ -344,11 +346,13 @@ 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". -local function execute_operatorfunc(cb) +---@param cb string Name of the gitlab.nvim API function to call, e.g., "create_comment". +---@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 "" @@ -385,12 +389,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", @@ -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", @@ -431,6 +434,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 3a6df9b5..5a47d2c9 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", }, }, 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