Files
gitlab.nvim/lua/gitlab/actions/discussions.lua
johnybx 58c3dcc9ec Discussion sign and diagnostics (#78)
This MR adds support for in-line comments in the review pane. This allows you to view comments (as diagnostics) directly in the Neovim buffers that you are reviewing. You can then jump to them directly in the discussion tree if you want to reply, edit, and so forth.
2023-11-13 09:06:04 -05:00

1015 lines
33 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 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 discussion_sign_name = "gitlab_discussion"
local discussion_helper_sign_start = "gitlab_discussion_helper_start"
local discussion_helper_sign_mid = "gitlab_discussion_helper_mid"
local discussion_helper_sign_end = "gitlab_discussion_helper_end"
local diagnostics_namespace = vim.api.nvim_create_namespace(discussion_sign_name)
local M = {
layout_visible = false,
layout = nil,
layout_buf = nil,
---@type Discussion[]
discussions = {},
---@type UnlinkedDiscussion[]
unlinked_discussions = {},
linked_section = nil,
unlinked_section = nil,
discussion_tree = nil,
}
---@class Author
---@field id integer
---@field username string
---@field email string
---@field name string
---@field state string
---@field avatar_url string
---@field web_url string
---@class LinePosition
---@field line_code string
---@field type string
---@class GitlabLineRange
---@field start LinePosition
---@field end LinePosition
---@class NotePosition
---@field base_sha string
---@field start_sha string
---@field head_sha string
---@field position_type string
---@field new_path string?
---@field new_line integer?
---@field old_path string?
---@field old_line integer?
---@field line_range GitlabLineRange?
---@class Note
---@field id integer
---@field type string
---@field body string
---@field attachment string
---@field title string
---@field file_name string
---@field author Author
---@field system boolean
---@field expires_at string?
---@field updated_at string?
---@field created_at string?
---@field noteable_id integer
---@field noteable_type string
---@field commit_id string
---@field position NotePosition
---@field resolvable boolean
---@field resolved boolean
---@field resolved_by Author
---@field resolved_at string?
---@field noteable_iid integer
---@class UnlinkedNote: Note
---@field position nil
---@class Discussion
---@field id string
---@field individual_note boolean
---@field notes Note[]
---@class UnlinkedDiscussion: Discussion
---@field notes UnlinkedNote[]
---@class DiscussionData
---@field discussions Discussion[]
---@field unlinked_discussions UnlinkedDiscussion[]
---Load the discussion data, storage them in M.discussions and M.unlinked_discussions and call
---callback with data
---@param callback fun(data: DiscussionData): nil
M.load_discussions = function(callback)
job.run_job("/discussions", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data)
M.discussions = data.discussions
M.unlinked_discussions = data.unlinked_discussions
callback(data)
end)
end
---Parse line code and return old and new line numbers
---@param line_code string gitlab line code -> 588440f66559714280628a4f9799f0c4eb880a4a_10_10
---@return number?
---@return number?
local function _parse_line_code(line_code)
local line_code_regex = "%w+_(%d+)_(%d+)"
local old_line, new_line = line_code:match(line_code_regex)
return tonumber(old_line), tonumber(new_line)
end
---Filter all discussions which are relevant for currently visible signs and diagnostscs.
---@return Discussion[]?
M.filter_discussions_for_signs_and_diagnostics = function()
if type(M.discussions) ~= "table" then
return
end
local file = reviewer.get_current_file()
if not file then
return
end
local discussions = {}
for _, discussion in ipairs(M.discussions) do
local first_note = discussion.notes[1]
if
type(first_note.position) == "table"
and (first_note.position.new_path == file or first_note.position.old_path == file)
then
if
--Skip resolved discussions
not (
state.settings.discussion_sign_and_diagnostic.skip_resolved_discussion
and first_note.resolvable
and first_note.resolved
)
--Skip discussions from old revisions
and not (
state.settings.discussion_sign_and_diagnostic.skip_old_revision_discussion
and first_note.position.base_sha ~= state.MR_REVISIONS[1].base_sha
)
then
table.insert(discussions, discussion)
end
end
end
return discussions
end
---Refresh the discussion signs for currently loaded file in reviewer For convinience we use same
---string for sign name and sign group ( currently there is only one sign needed)
M.refresh_signs = function()
local diagnostics = M.filter_discussions_for_signs_and_diagnostics()
if diagnostics == nil then
vim.diagnostic.reset(diagnostics_namespace)
return
end
local new_signs = {}
local old_signs = {}
for _, discussion in ipairs(diagnostics) do
local first_note = discussion.notes[1]
local base_sign = {
name = discussion_sign_name,
group = discussion_sign_name,
priority = state.settings.discussion_sign.priority,
}
local base_helper_sign = {
name = discussion_sign_name,
group = discussion_sign_name,
priority = state.settings.discussion_sign.priority - 1,
}
if first_note.position.line_range ~= nil then
local start_old_line, start_new_line = _parse_line_code(first_note.position.line_range.start.line_code)
local end_old_line, end_new_line = _parse_line_code(first_note.position.line_range["end"].line_code)
local discussion_line, start_line, end_line
if first_note.position.line_range.start.type == "new" then
table.insert(
new_signs,
vim.tbl_deep_extend("force", {
id = first_note.id,
lnum = first_note.position.new_line,
}, base_sign)
)
discussion_line = first_note.position.new_line
start_line = start_new_line
end_line = end_new_line
elseif first_note.position.line_range.start.type == "old" then
table.insert(
old_signs,
vim.tbl_deep_extend("force", {
id = first_note.id,
lnum = first_note.position.old_line,
}, base_sign)
)
discussion_line = first_note.position.old_line
start_line = start_old_line
end_line = end_old_line
end
-- Helper signs does not have specific ids currently.
if state.settings.discussion_sign.helper_signs.enabled then
local helper_signs = {}
if start_line > end_line then
start_line, end_line = end_line, start_line
end
for i = start_line, end_line do
if i ~= discussion_line then
local sign_name
if i == start_line then
sign_name = discussion_helper_sign_start
elseif i == end_line then
sign_name = discussion_helper_sign_end
else
sign_name = discussion_helper_sign_mid
end
table.insert(
helper_signs,
vim.tbl_deep_extend("keep", {
name = sign_name,
lnum = i,
}, base_helper_sign)
)
end
end
if first_note.position.line_range.start.type == "new" then
vim.list_extend(new_signs, helper_signs)
elseif first_note.position.line_range.start.type == "old" then
vim.list_extend(old_signs, helper_signs)
end
end
else
local sign = vim.tbl_deep_extend("force", {
id = first_note.id,
}, base_sign)
if first_note.position.new_line ~= nil then
table.insert(new_signs, vim.tbl_deep_extend("force", { lnum = first_note.position.new_line }, sign))
end
if first_note.position.old_line ~= nil then
table.insert(old_signs, vim.tbl_deep_extend("force", { lnum = first_note.position.old_line }, sign))
end
end
end
vim.fn.sign_unplace(discussion_sign_name)
reviewer.place_sign(old_signs, "old")
reviewer.place_sign(new_signs, "new")
end
---Refresh the diagnostics for the currently reviewed file
M.refresh_diagnostics = function()
-- Keep in mind that diagnostic line numbers use 0-based indexing while line numbers use
-- 1-based indexing
local diagnostics = M.filter_discussions_for_signs_and_diagnostics()
if diagnostics == nil then
vim.diagnostic.reset(diagnostics_namespace)
return
end
local new_diagnostics = {}
local old_diagnostics = {}
for _, discussion in ipairs(diagnostics) do
local first_note = discussion.notes[1]
local message = ""
for _, note in ipairs(discussion.notes) do
message = message .. M.build_note_header(note) .. "\n" .. note.body .. "\n"
end
local diagnostic = {
message = message,
col = 0,
severity = state.settings.discussion_diagnostic.severity,
user_data = { discussion_id = discussion.id, header = M.build_note_header(discussion.notes[1]) },
source = "gitlab",
code = state.settings.discussion_diagnostic.code,
}
if first_note.position.line_range ~= nil then
-- Diagnostics for line range discussions are tricky - you need to set lnum to
-- line number equal to note.position.new_line or note.position.old_line because that is
-- only line where you can trigger the diagnostic show. This also need to be in sinc
-- with the sign placement.
local start_old_line, start_new_line = _parse_line_code(first_note.position.line_range.start.line_code)
local end_old_line, end_new_line = _parse_line_code(first_note.position.line_range["end"].line_code)
if first_note.position.line_range.start.type == "new" then
local new_diagnostic
if first_note.position.new_line == start_new_line then
new_diagnostic = {
lnum = start_new_line - 1,
end_lnum = end_new_line - 1,
}
else
new_diagnostic = {
lnum = end_new_line - 1,
end_lnum = start_new_line - 1,
}
end
new_diagnostic = vim.tbl_deep_extend("force", new_diagnostic, diagnostic)
table.insert(new_diagnostics, new_diagnostic)
elseif first_note.position.line_range.start.type == "old" then
local old_diagnostic
if first_note.position.old_line == start_old_line then
old_diagnostic = {
lnum = start_old_line - 1,
end_lnum = end_old_line - 1,
}
else
old_diagnostic = {
lnum = end_old_line - 1,
end_lnum = start_old_line - 1,
}
end
old_diagnostic = vim.tbl_deep_extend("force", old_diagnostic, diagnostic)
table.insert(old_diagnostics, old_diagnostic)
end
else
-- Diagnostics for single line discussions.
if first_note.position.new_line ~= nil then
local new_diagnostic = {
lnum = first_note.position.new_line - 1,
}
new_diagnostic = vim.tbl_deep_extend("force", new_diagnostic, diagnostic)
table.insert(new_diagnostics, new_diagnostic)
end
if first_note.position.old_line ~= nil then
local old_diagnostic = {
lnum = first_note.position.old_line - 1,
}
old_diagnostic = vim.tbl_deep_extend("force", old_diagnostic, diagnostic)
table.insert(old_diagnostics, old_diagnostic)
end
end
end
vim.diagnostic.reset(diagnostics_namespace)
reviewer.set_diagnostics(
diagnostics_namespace,
new_diagnostics,
"new",
state.settings.discussion_diagnostic.display_opts
)
reviewer.set_diagnostics(
diagnostics_namespace,
old_diagnostics,
"old",
state.settings.discussion_diagnostic.display_opts
)
end
---Refresh discussion data, discussion signs and diagnostics
M.refresh_discussion_data = function()
M.load_discussions(function()
if state.settings.discussion_sign.enabled then
M.refresh_signs()
end
if state.settings.discussion_diagnostic.enabled then
M.refresh_diagnostics()
end
end)
end
---Define signs for discussions if not already defined
M.setup_signs = function()
local discussion_sign = state.settings.discussion_sign
local signs = {
[discussion_sign_name] = discussion_sign.text,
[discussion_helper_sign_start] = discussion_sign.helper_signs.start,
[discussion_helper_sign_mid] = discussion_sign.helper_signs.mid,
[discussion_helper_sign_end] = discussion_sign.helper_signs["end"],
}
for sign_name, sign_text in pairs(signs) do
if #vim.fn.sign_getdefined(sign_name) == 0 then
vim.fn.sign_define(sign_name, {
text = sign_text,
linehl = discussion_sign.linehl,
texthl = discussion_sign.texthl,
culhl = discussion_sign.culhl,
numhl = discussion_sign.numhl,
})
end
end
end
---Initialize everything for discussions like setup of signs, callbacks for reviewer, etc.
M.initialize_discussions = function()
M.setup_signs()
M.setup_refresh_discussion_data_callback()
M.setup_leave_reviewer_callback()
end
---Setup callback to refresh discussion data, discussion signs and diagnostics whenever the
---reviewed file changes.
M.setup_refresh_discussion_data_callback = function()
reviewer.set_callback_for_file_changed(M.refresh_discussion_data)
end
---Clear all signs and diagnostics
M.clear_signs_and_discussions = function()
vim.fn.sign_unplace(discussion_sign_name)
vim.diagnostic.reset(diagnostics_namespace)
end
---Setup callback to clear signs and diagnostics whenever reviewer is left.
M.setup_leave_reviewer_callback = function()
reviewer.set_callback_for_reviewer_leave(M.clear_signs_and_discussions)
end
M.refresh_discussion_tree = function()
if M.layout_visible == false then
return
end
if type(M.discussions) == "table" then
M.rebuild_discussion_tree()
end
if type(M.unlinked_discussions) == "table" then
M.rebuild_unlinked_discussion_tree()
end
M.switch_can_edit_bufs(true)
M.add_empty_titles({
{ M.linked_section.bufnr, M.discussions, "No Discussions for this MR" },
{ M.unlinked_section.bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" },
})
M.switch_can_edit_bufs(false)
end
---Opens the discussion tree, sets the keybindings. It also
---creates the tree for notes (which are not linked to specific lines of code)
---@param callback function?
M.toggle = function(callback)
if M.layout_visible then
M.layout:unmount()
M.layout_visible = false
M.discussion_tree = nil
M.linked_section = nil
M.unlinked_section = nil
return
end
local linked_section, unlinked_section, layout = M.create_layout()
M.linked_section = linked_section
M.unlinked_section = unlinked_section
M.load_discussions(function()
if type(M.discussions) ~= "table" and type(M.unlinked_discussions) ~= "table" then
vim.notify("No discussions or notes for this MR", vim.log.levels.WARN)
return
end
layout:mount()
layout:show()
M.layout = layout
M.layout_visible = true
M.layout_buf = layout.bufnr
state.discussion_buf = layout.bufnr
M.refresh_discussion_tree()
if type(callback) == "function" then
callback()
end
end)
end
---Move to the discussion tree at the discussion from diagnostic on current line.
M.move_to_discussion_tree = function()
local current_line = vim.api.nvim_win_get_cursor(0)[1]
local diagnostics = vim.diagnostic.get(0, { namespace = diagnostics_namespace, lnum = current_line - 1 })
---Function used to jump to the discussion tree after the menu selection.
local jump_after_menu_selection = function(diagnostic)
---Function used to jump to the discussion tree after the discussion tree is opened.
local jump_after_tree_opened = function()
-- All diagnostics in `diagnotics_namespace` have diagnostic_id
local discussion_id = diagnostic.user_data.discussion_id
local discussion_node, line_number = M.discussion_tree:get_node("-" .. discussion_id)
if discussion_node == {} or discussion_node == nil then
vim.notify("Discussion not found", vim.log.levels.WARN)
return
end
if not discussion_node:is_expanded() then
for _, child in ipairs(discussion_node:get_child_ids()) do
M.discussion_tree:get_node(child):expand()
end
discussion_node:expand()
end
M.discussion_tree:render()
vim.api.nvim_win_set_cursor(M.linked_section.winid, { line_number, 0 })
vim.api.nvim_set_current_win(M.linked_section.winid)
end
if not M.layout_visible then
M.toggle(jump_after_tree_opened)
else
jump_after_tree_opened()
end
end
if #diagnostics == 0 then
vim.notify("No diagnostics for this line", vim.log.levels.WARN)
return
elseif #diagnostics > 1 then
vim.ui.select(diagnostics, {
prompt = "Choose discussion to jump to",
format_item = function(diagnostic)
return diagnostic.message
end,
}, function(diagnostic)
if not diagnostic then
return
end
jump_after_menu_selection(diagnostic)
end)
else
jump_after_menu_selection(diagnostics[1])
end
end
-- The reply popup will mount in a window when you trigger it (settings.discussion_tree.reply) when hovering over a node in the discussion tree.
M.reply = function(tree)
local node = tree:get_node()
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)
u.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)
vim.ui.select({ "Confirm", "Cancel" }, {
prompt = "Delete comment?",
}, function(choice)
if choice == "Cancel" then
return
end
M.send_deletion(tree, unlinked)
end)
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, 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)
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)
u.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
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(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(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)
u.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)
u.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
u.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
u.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)
vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_section.bufnr })
end
M.rebuild_unlinked_discussion_tree = function()
M.switch_can_edit_bufs(true)
vim.api.nvim_buf_set_lines(M.unlinked_section.bufnr, 0, -1, false, {})
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)
vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_section.bufnr })
end
M.switch_can_edit_bufs = function(bool)
u.switch_can_edit_buf(M.unlinked_section.bufnr, bool)
u.switch_can_edit_buf(M.linked_section.bufnr, bool)
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)
if M.unlinked_section ~= nil then
M.rebuild_unlinked_discussion_tree()
end
return
end
if type(M.discussions) ~= "table" then
M.discussions = {}
end
table.insert(M.discussions, 1, discussion)
if M.linked_section ~= nil 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
---Build note header from note.
---@param note Note
---@return string
M.build_note_header = function(note)
return "@" .. note.author.username .. " " .. u.format_date(note.created_at)
end
M.build_note_body = function(note, resolve_info)
local text_nodes = {}
for bodyLine in note.body:gmatch("[^\n]+") do
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 = M.build_note_header(note) .. " " .. 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