Files
gitlab.nvim/lua/gitlab/actions/comment.lua
Harrison (Harry) Cramer 9fc47bd3bc feat!: Implements default keybindings for all actions.
This is a breaking change to the way the plugin is configured. If users are using the old configuration the plugin will warn them which fields have been removed in their configuration. Old keybindings can be found here: https://github.com/harrisoncramer/gitlab.nvim/pull/340#issuecomment-2282756924 (#331)

feat: Customize discussion tree chevrons (#339)
2024-08-11 22:29:08 -04:00

342 lines
11 KiB
Lua

--- This module is responsible for creating new comments
--- in the reviewer's buffer. The reviewer will pass back
--- to this module the data required to make the API calls
local Popup = require("nui.popup")
local Layout = require("nui.layout")
local state = require("gitlab.state")
local job = require("gitlab.job")
local u = require("gitlab.utils")
local git = require("gitlab.git")
local discussions = require("gitlab.actions.discussions")
local draft_notes = require("gitlab.actions.draft_notes")
local miscellaneous = require("gitlab.actions.miscellaneous")
local reviewer = require("gitlab.reviewer")
local Location = require("gitlab.reviewer.location")
local M = {
current_win = nil,
start_line = nil,
end_line = nil,
draft_popup = nil,
comment_popup = nil,
}
---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 visual_range LineRange | nil range of visual selection or nil
---@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, visual_range, 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))
-- Creating a normal reply to a discussion
if discussion_id ~= nil and not is_draft then
local body = { discussion_id = discussion_id, reply = text, draft = is_draft }
job.run_job("/mr/reply", "POST", body, function()
u.notify("Sent reply!", vim.log.levels.INFO)
if is_draft then
draft_notes.load_draft_notes(function()
discussions.rebuild_view(unlinked)
end)
else
discussions.rebuild_view(unlinked)
end
end)
return
end
-- Creating a note (unlinked comment)
if unlinked and discussion_id == nil then
local body = { comment = text }
local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment"
job.run_job(endpoint, "POST", body, function()
u.notify(is_draft and "Draft note created!" or "Note created!", vim.log.levels.INFO)
if is_draft then
draft_notes.load_draft_notes(function()
discussions.rebuild_view(unlinked)
end)
else
discussions.rebuild_view(unlinked)
end
end)
return
end
local reviewer_data = reviewer.get_reviewer_data()
if reviewer_data == nil then
u.notify("Error getting reviewer data", vim.log.levels.ERROR)
return
end
local location = Location.new(reviewer_data, visual_range)
location:build_location_data()
local location_data = location.location_data
if location_data == nil then
u.notify("Error getting location information", vim.log.levels.ERROR)
return
end
local revision = state.MR_REVISIONS[1]
local position_data = {
file_name = reviewer_data.file_name,
base_commit_sha = revision.base_commit_sha,
start_commit_sha = revision.start_commit_sha,
head_commit_sha = revision.head_commit_sha,
old_line = location_data.old_line,
new_line = location_data.new_line,
line_range = location_data.line_range,
}
-- Creating a draft reply, in response to a discussion ID
if discussion_id ~= nil and is_draft then
local body = { comment = text, discussion_id = discussion_id, position = position_data }
job.run_job("/mr/draft_notes/", "POST", body, function()
u.notify("Draft reply created!", vim.log.levels.INFO)
draft_notes.load_draft_notes(function()
discussions.rebuild_view(false, true)
end)
end)
return
end
-- Creating a new comment (linked to specific changes)
local body = u.merge({ type = "text", comment = text }, position_data)
local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment"
job.run_job(endpoint, "POST", body, function()
u.notify(is_draft and "Draft comment created!" or "Comment created!", vim.log.levels.INFO)
if is_draft then
draft_notes.load_draft_notes(function()
discussions.rebuild_view(unlinked)
end)
else
discussions.rebuild_view(unlinked)
end
end)
end
-- This function will actually send the deletion to Gitlab when you make a selection,
-- and re-render the tree
---@param note_id integer
---@param discussion_id string
---@param unlinked boolean
M.confirm_delete_comment = function(note_id, discussion_id, unlinked)
local body = { discussion_id = discussion_id, note_id = tonumber(note_id) }
job.run_job("/mr/comment", "DELETE", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
discussions.rebuild_view(unlinked)
end)
end
---This function sends the edited comment to the Go server
---@param discussion_id string
---@param note_id integer
---@param unlinked boolean
M.confirm_edit_comment = function(discussion_id, note_id, unlinked)
return function(text)
local body = {
discussion_id = discussion_id,
note_id = note_id,
comment = text,
}
job.run_job("/mr/comment", "PATCH", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
discussions.rebuild_view(unlinked)
end)
end
end
---@class LayoutOpts
---@field ranged boolean
---@field discussion_id string|nil
---@field unlinked boolean
---This function sets up the layout and popups needed to create a comment, note and
---multi-line comment. It also sets up the basic keybindings for switching between
---window panes, and for the non-primary sections.
---@param opts LayoutOpts|nil
---@return NuiLayout
M.create_comment_layout = function(opts)
if opts == nil then
opts = {}
end
local title = opts.discussion_id and "Reply" or "Comment"
local settings = opts.discussion_id ~= nil and state.settings.popup.reply or state.settings.popup.comment
M.current_win = vim.api.nvim_get_current_win()
M.comment_popup = Popup(u.create_popup_state(title, settings))
M.draft_popup = Popup(u.create_box_popup_state("Draft", false))
M.start_line, M.end_line = u.get_visual_selection_boundaries()
local internal_layout = Layout.Box({
Layout.Box(M.comment_popup, { grow = 1 }),
Layout.Box(M.draft_popup, { size = 3 }),
}, { dir = "col" })
local layout = Layout({
position = "50%",
relative = "editor",
size = {
width = "50%",
height = "55%",
},
}, internal_layout)
miscellaneous.set_cycle_popups_keymaps({ M.comment_popup, M.draft_popup })
local range = opts.ranged and { start_line = M.start_line, end_line = M.end_line } or nil
local unlinked = opts.unlinked or false
---Keybinding for focus on draft section
state.set_popup_keymaps(M.draft_popup, function()
local text = u.get_buffer_text(M.comment_popup.bufnr)
confirm_create_comment(text, range, unlinked, opts.discussion_id)
vim.api.nvim_set_current_win(M.current_win)
end, miscellaneous.toggle_bool, miscellaneous.non_editable_popup_opts)
---Keybinding for focus on text section
state.set_popup_keymaps(M.comment_popup, function(text)
confirm_create_comment(text, range, unlinked, opts.discussion_id)
vim.api.nvim_set_current_win(M.current_win)
end, miscellaneous.attach_file, miscellaneous.editable_popup_opts)
vim.schedule(function()
local draft_mode = state.settings.discussion_tree.draft_mode
vim.api.nvim_buf_set_lines(M.draft_popup.bufnr, 0, -1, false, { u.bool_to_string(draft_mode) })
end)
--Send back to previous window on close
vim.api.nvim_create_autocmd("BufHidden", {
buffer = M.draft_popup.bufnr,
callback = function()
vim.api.nvim_set_current_win(M.current_win)
end,
})
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()
local has_clean_tree, err = git.has_clean_tree()
if err ~= nil then
return
end
local is_modified = vim.api.nvim_buf_get_option(0, "modified")
if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then
u.notify(
"Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.",
vim.log.levels.WARN
)
return
end
if not M.sha_exists() then
return
end
local layout = M.create_comment_layout({ ranged = false, 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()
if not u.check_visual_mode() then
return
end
if not M.sha_exists() then
return
end
local layout = M.create_comment_layout({ ranged = true, 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()
local layout = M.create_comment_layout({ ranged = false, unlinked = true })
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
---@return integer
local build_suggestion = function()
local current_line = vim.api.nvim_win_get_cursor(0)[1]
M.start_line, M.end_line = u.get_visual_selection_boundaries()
local range_length = M.end_line - M.start_line
local backticks = "```"
local selected_lines = u.get_lines(M.start_line, M.end_line)
for _, line in ipairs(selected_lines) do
if string.match(line, "^```%S*$") then
backticks = "````"
break
end
end
local suggestion_start
if M.start_line == current_line then
suggestion_start = backticks .. "suggestion:-0+" .. range_length
elseif M.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, 0
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, range_length
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()
if not u.check_visual_mode() then
return
end
if not M.sha_exists() then
return
end
local suggestion_lines, range_length = build_suggestion()
local layout = M.create_comment_layout({ ranged = range_length > 0, 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
---Checks to see whether you are commenting on a valid buffer. The Diffview plugin names non-existent
---buffers as 'null'
---@return boolean
M.sha_exists = function()
if vim.fn.expand("%") == "diffview://null" then
u.notify("This file does not exist, please comment on the other buffer", vim.log.levels.ERROR)
return false
end
return true
end
return M