Files
gitlab.nvim/lua/gitlab/actions/discussions.lua
Harrison (Harry) Cramer 4e473dab7e Feat: Upload Files (#59)
This MR adds the ability to add files to comments, notes, replys, and MR descriptions via a picker.

The file will get uploaded to Gitlab and the filepath will be automatically added into the current popup buffer at the current line. You can then save the changes with the normal save functionality.
2023-09-08 10:02:01 -04:00

569 lines
19 KiB
Lua

-- This module is responsible for the discussion tree. That includes things like
-- editing existing notes in the tree, replying to notes in the tree,
-- and marking discussions as resolved/unresolved.
local Split = require("nui.split")
local Popup = require("nui.popup")
local Menu = require("nui.menu")
local NuiTree = require("nui.tree")
local Layout = require("nui.layout")
local job = require("gitlab.job")
local u = require("gitlab.utils")
local state = require("gitlab.state")
local reviewer = require("gitlab.reviewer")
local miscellaneous = require("gitlab.actions.miscellaneous")
local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%"))
local reply_popup = Popup(u.create_popup_state("Reply", "80%", "80%"))
local M = {
layout_visible = false,
layout = nil,
layout_buf = nil,
discussions = {},
unlinked_discussions = {},
linked_section_bufnr = -1,
unlinked_section_bufnr = -1,
}
-- Opens the discussion tree, sets the keybindings. It also
-- creates the tree for notes (which are not linked to specific lines of code)
M.toggle = function()
if M.layout_visible then
M.layout:unmount()
M.layout_visible = false
return
end
local linked_section, unlinked_section, layout = M.create_layout()
M.linked_section_bufnr = linked_section.bufnr
M.unlinked_section_bufnr = unlinked_section.bufnr
job.run_job("/discussions", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data)
if type(data.discussions) ~= "table" and type(data.unlinked_discussions) ~= "table" then
vim.notify("No discussions or notes for this MR", vim.log.levels.WARN)
return
end
layout:mount()
layout:show()
M.layout = layout
M.layout_visible = true
M.layout_buf = layout.bufnr
state.discussion_buf = layout.bufnr
M.discussions = data.discussions
M.unlinked_discussions = data.unlinked_discussions
if type(data.discussions) == "table" then M.rebuild_discussion_tree() end
if type(data.unlinked_discussions) == "table" then M.rebuild_unlinked_discussion_tree() end
M.switch_can_edit_bufs(true)
M.add_empty_titles({
{ linked_section.bufnr, data.discussions, "No Discussions for this MR" },
{ unlinked_section.bufnr, data.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }
})
M.switch_can_edit_bufs(false)
end)
end
-- The reply popup will mount in a window when you trigger it (settings.discussion_tree.reply) when hovering over a node in the discussion tree.
M.reply = function(tree)
local node = tree:get_node()
local discussion_node = M.get_root_node(tree, node)
local id = tostring(discussion_node.id)
reply_popup:mount()
state.set_popup_keymaps(reply_popup, M.send_reply(tree, id), miscellaneous.attach_file)
end
-- This function will send the reply to the Go API
M.send_reply = function(tree, discussion_id)
return function(text)
local body = { discussion_id = discussion_id, reply = text }
job.run_job("/reply", "POST", body, function(data)
vim.notify("Sent reply!", vim.log.levels.INFO)
M.add_reply_to_tree(tree, data.note, discussion_id)
end)
end
end
-- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment
M.delete_comment = function(tree, unlinked)
local menu = Menu({
position = "50%",
size = {
width = 25,
},
border = {
style = "single",
text = {
top = "Delete Comment?",
top_align = "center",
},
},
win_options = {
winhighlight = "Normal:Normal,FloatBorder:Normal",
},
}, {
lines = {
Menu.item("Confirm"),
Menu.item("Cancel"),
},
max_width = 20,
keymap = {
focus_next = state.settings.dialogue.focus_next,
focus_prev = state.settings.dialogue.focus_prev,
close = state.settings.dialogue.close,
submit = state.settings.dialogue.submit,
},
on_submit = function(item)
M.send_deletion(tree, item, unlinked)
end
})
menu:mount()
end
-- This function will actually send the deletion to Gitlab
-- when you make a selection, and re-render the tree
M.send_deletion = function(tree, item, unlinked)
if item.text == "Confirm" then
local current_node = tree:get_node()
local note_node = M.get_note_node(tree, current_node)
local root_node = M.get_root_node(tree, current_node)
local note_id = note_node.is_root and root_node.root_note_id or note_node.id
local body = { discussion_id = root_node.id, note_id = note_id }
job.run_job("/comment", "DELETE", body, function(data)
vim.notify(data.message, vim.log.levels.INFO)
if not note_node.is_root then
tree:remove_node("-" .. note_id) -- Note is not a discussion root, safe to remove
tree:render()
else
if unlinked then
M.unlinked_discussions = u.remove_first_value(M.unlinked_discussions)
M.rebuild_unlinked_discussion_tree()
else
M.discussions = u.remove_first_value(M.discussions)
M.rebuild_discussion_tree()
end
end
M.switch_can_edit_bufs(true)
M.add_empty_titles({
{ M.linked_section_bufnr, M.discussions, "No Discussions for this MR" },
{ M.unlinked_section_bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }
})
M.switch_can_edit_bufs(false)
end)
end
end
-- This function (settings.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree
M.edit_comment = function(tree, unlinked)
local current_node = tree:get_node()
local note_node = M.get_note_node(tree, current_node)
local root_node = M.get_root_node(tree, current_node)
edit_popup:mount()
local lines = {} -- Gather all lines from immediate children that aren't note nodes
local children_ids = note_node:get_child_ids()
for _, child_id in ipairs(children_ids) do
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(lines, line)
end
end
local currentBuffer = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines)
state.set_popup_keymaps(edit_popup,
M.send_edits(tree, tostring(root_node.id), note_node.root_note_id or note_node.id, unlinked))
end
-- This function sends the edited comment to the Go server
M.send_edits = function(tree, discussion_id, note_id, unlinked)
return function(text)
local body = {
discussion_id = discussion_id,
note_id = note_id,
comment = text
}
job.run_job("/comment", "PATCH", body, function(data)
vim.notify(data.message, vim.log.levels.INFO)
if unlinked then
M.unlinked_discussions = M.replace_text(M.unlinked_discussions, discussion_id, note_id, text)
M.rebuild_unlinked_discussion_tree()
else
M.discussions = M.replace_text(M.discussions, discussion_id, note_id, text)
M.rebuild_discussion_tree()
end
end)
end
end
-- This comment (settings.discussion_tree.toggle_resolved) will toggle the resolved status of the current discussion and send the change to the Go server
M.toggle_resolved = function(tree)
local note = tree:get_node()
if not note or not note.resolvable then return end
local body = {
discussion_id = note.id,
note_id = note.root_note_id,
resolved = not note.resolved,
}
job.run_job("/comment", "PATCH", body, function(data)
vim.notify(data.message, vim.log.levels.INFO)
M.redraw_resolved_status(tree, note, not note.resolved)
end)
end
-- This function (settings.discussion_tree.jump_to_reviewer) will jump the cursor to the reviewer's location associated with the note. The implementation depends on the reviewer
M.jump_to_reviewer = function(tree)
local file_name, new_line, old_line, error = M.get_note_location(tree)
if error ~= nil then
vim.notify(error, vim.log.levels.ERROR)
return
end
reviewer.jump(file_name, new_line, old_line)
end
-- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab
M.jump_to_file = function(tree)
local file_name, new_line, old_line, error = M.get_note_location(tree)
if error ~= nil then
vim.notify(error, vim.log.levels.ERROR)
return
end
vim.cmd.tabnew()
u.jump_to_file(file_name, (new_line or old_line))
end
-- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children
M.toggle_node = function(tree)
local node = tree:get_node()
if node == nil then return end
local children = node:get_child_ids()
if node == nil then return end
if node:is_expanded() then
node:collapse()
for _, child in ipairs(children) do
tree:get_node(child):collapse()
end
else
for _, child in ipairs(children) do
tree:get_node(child):expand()
end
node:expand()
end
tree:render()
end
--
-- 🌲 Helper Functions
--
M.rebuild_discussion_tree = function()
M.switch_can_edit_bufs(true)
vim.api.nvim_buf_set_lines(M.linked_section_bufnr, 0, -1, false, {})
local discussion_tree_nodes = M.add_discussions_to_table(M.discussions)
local discussion_tree = NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_section_bufnr })
discussion_tree:render()
M.set_tree_keymaps(discussion_tree, M.linked_section_bufnr, false)
M.discussion_tree = discussion_tree
M.switch_can_edit_bufs(false)
end
M.rebuild_unlinked_discussion_tree = function()
M.switch_can_edit_bufs(true)
vim.api.nvim_buf_set_lines(M.unlinked_section_bufnr, 0, -1, false, {})
local unlinked_discussion_tree_nodes = M.add_discussions_to_table(M.unlinked_discussions)
local unlinked_discussion_tree = NuiTree({ nodes = unlinked_discussion_tree_nodes, bufnr = M.unlinked_section_bufnr })
unlinked_discussion_tree:render()
M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_section_bufnr, true)
M.unlinked_discussion_tree = unlinked_discussion_tree
M.switch_can_edit_bufs(false)
end
M.switch_can_edit_bufs = function(bool)
u.switch_can_edit_buf(M.unlinked_section_bufnr, bool)
u.switch_can_edit_buf(M.linked_section_bufnr, bool)
end
M.add_discussion = function(arg)
local discussion = arg.data.discussion
if arg.unlinked then
if type(M.unlinked_discussions) ~= "table" then M.unlinked_discussions = {} end
table.insert(M.unlinked_discussions, 1, discussion)
local bufinfo = vim.fn.getbufinfo(M.unlinked_section_bufnr)
if u.table_size(bufinfo) ~= 0 then
M.rebuild_unlinked_discussion_tree()
end
return
end
if type(M.discussions) ~= "table" then M.discussions = {} end
table.insert(M.discussions, 1, discussion)
local bufinfo = vim.fn.getbufinfo(M.unlinked_section_bufnr)
if u.table_size(bufinfo) ~= 0 then
M.rebuild_discussion_tree()
end
end
M.create_layout = function()
local linked_section = Split({ enter = true })
local unlinked_section = Split({})
local position = state.settings.discussion_tree.position
local size = state.settings.discussion_tree.size
local relative = state.settings.discussion_tree.relative
local layout = Layout(
{
position = position,
size = size,
relative = relative,
},
Layout.Box({
Layout.Box(linked_section, { size = "50%" }),
Layout.Box(unlinked_section, { size = "50%" }),
},
{ dir = (position == "left" and "col" or "row") }
)
)
return linked_section, unlinked_section, layout
end
M.add_empty_titles = function(args)
local ns_id = vim.api.nvim_create_namespace("GitlabNamespace")
vim.cmd("highlight default TitleHighlight guifg=#787878")
for _, section in ipairs(args) do
local bufnr, data, title = section[1], section[2], section[3]
if type(data) ~= "table" or #data == 0 then
vim.api.nvim_buf_set_lines(bufnr, 0, 1, false, { title })
local linnr = 1
vim.api.nvim_buf_set_extmark(bufnr, ns_id, linnr - 1, 0,
{ end_row = linnr - 1, end_col = string.len(title), hl_group = 'TitleHighlight' })
end
end
end
M.set_tree_keymaps = function(tree, bufnr, unlinked)
vim.keymap.set('n',
state.settings.discussion_tree.edit_comment,
function() M.edit_comment(tree, unlinked) end,
{ buffer = bufnr }
)
vim.keymap.set('n',
state.settings.discussion_tree.delete_comment,
function() M.delete_comment(tree, unlinked) end,
{ buffer = bufnr }
)
vim.keymap.set('n',
state.settings.discussion_tree.toggle_resolved,
function() M.toggle_resolved(tree) end,
{ buffer = bufnr }
)
vim.keymap.set('n',
state.settings.discussion_tree.toggle_node,
function() M.toggle_node(tree, unlinked) end,
{ buffer = bufnr }
)
vim.keymap.set('n',
state.settings.discussion_tree.reply,
function() M.reply(tree) end,
{ buffer = bufnr }
)
if not unlinked then
vim.keymap.set('n', state.settings.discussion_tree.jump_to_file, function()
M.jump_to_file(tree)
end, { buffer = bufnr }
)
vim.keymap.set('n', state.settings.discussion_tree.jump_to_reviewer,
function() M.jump_to_reviewer(tree) end,
{ buffer = bufnr }
)
end
end
M.redraw_resolved_status = function(tree, note, mark_resolved)
local current_text = tree.nodes.by_id["-" .. note.id].text
local target = mark_resolved and 'resolved' or 'unresolved'
local current = mark_resolved and 'unresolved' or 'resolved'
local function set_property(key, val)
tree.nodes.by_id["-" .. note.id][key] = val
end
local has_symbol = function(s)
return state.settings.discussion_tree[s] ~= nil and state.settings.discussion_tree[s] ~= ''
end
set_property('resolved', mark_resolved)
if not has_symbol(current) and not has_symbol(target) then return end
if not has_symbol(current) and has_symbol(target) then
set_property('text', (current_text .. " " .. state.settings.discussion_tree[target]))
elseif has_symbol(current) and not has_symbol(target) then
set_property('text', u.remove_last_chunk(current_text))
else
set_property('text', (u.remove_last_chunk(current_text) .. " " .. state.settings.discussion_tree[target]))
end
tree:render()
end
M.replace_text = function(data, discussion_id, note_id, text)
for i, discussion in ipairs(data) do
if discussion.id == discussion_id then
for j, note in ipairs(discussion.notes) do
if note.id == note_id then
data[i].notes[j].body = text
return data
end
end
end
end
end
M.get_root_node = function(tree, node)
if (not node.is_root) then
local parent_id = node:get_parent_id()
return M.get_root_node(tree, tree:get_node(parent_id))
else
return node
end
end
M.get_note_node = function(tree, node)
if (not node.is_note) then
local parent_id = node:get_parent_id()
if parent_id == nil then return node end
return M.get_note_node(tree, tree:get_node(parent_id))
else
return node
end
end
local attach_uuid = function(str)
return { text = str, id = u.uuid() }
end
M.build_note_body = function(note, resolve_info)
local text_nodes = {}
for bodyLine in note.body:gmatch("[^\n]+") do
local line = attach_uuid(bodyLine)
table.insert(text_nodes, NuiTree.Node({
new_line = (type(note.position) == "table" and note.position.new_line),
old_line = (type(note.position) == "table" and note.position.old_line),
text = line.text,
id = line.id,
is_body = true
}, {}))
end
local resolve_symbol = ''
if resolve_info ~= nil and resolve_info.resolvable then
resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved or
state.settings.discussion_tree.unresolved
end
local noteHeader = "@" .. note.author.username .. " " .. u.format_date(note.created_at) .. " " .. resolve_symbol
return noteHeader, text_nodes
end
M.build_note = function(note, resolve_info)
local text, text_nodes = M.build_note_body(note, resolve_info)
local note_node = NuiTree.Node({
text = text,
id = note.id,
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),
is_note = true,
}, text_nodes)
return note_node, text, text_nodes
end
M.add_reply_to_tree = function(tree, note, discussion_id)
local note_node = M.build_note(note)
note_node:expand()
tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil)
tree:render()
end
M.add_discussions_to_table = function(items)
local t = {}
for _, discussion in ipairs(items) do
local discussion_children = {}
-- These properties are filled in by the first note
local root_text = ''
local root_note_id = ''
local root_file_name = ''
local root_id = 0
local root_text_nodes = {}
local resolvable = false
local resolved = false
local root_new_line = nil
local root_old_line = nil
for j, note in ipairs(discussion.notes) do
if j == 1 then
__, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable })
root_file_name = (type(note.position) == "table" and note.position.new_path)
root_new_line = (type(note.position) == "table" and note.position.new_line)
root_old_line = (type(note.position) == "table" and note.position.old_line)
root_id = discussion.id
root_note_id = note.id
resolvable = note.resolvable
resolved = note.resolved
else -- Otherwise insert it as a child node...
local note_node = M.build_note(note)
table.insert(discussion_children, note_node)
end
end
-- Creates the first node in the discussion, and attaches children
local body = u.join_tables(root_text_nodes, discussion_children)
local root_node = NuiTree.Node({
text = root_text,
is_note = true,
is_root = true,
id = root_id,
root_note_id = root_note_id,
file_name = root_file_name,
new_line = root_new_line,
old_line = root_old_line,
resolvable = resolvable,
resolved = resolved
}, body)
table.insert(t, root_node)
end
return t
end
M.get_note_location = function(tree)
local node = tree:get_node()
if node == nil then return nil, nil, nil, "Could not get node" end
local discussion_node = M.get_root_node(tree, node)
if discussion_node == nil then return nil, nil, nil, "Could not get discussion node" end
return discussion_node.file_name, discussion_node.new_line, discussion_node.old_line
end
return M