Tree Refresh; Draft Note Replies (#289)

* fix: always refresh discussion tree data after choosing a new branch 
* fix: rebuild discussion tree without collapsing nodes after all edit/delete/create actions
* feat: add command to refresh discussion tree
* feat: Add support for draft note replies, e.g. replies to existing notes and comments in draft form
* fix: allow backticks in comment suggestions

This is a #MINOR release
This commit is contained in:
Harrison (Harry) Cramer
2024-04-25 19:15:08 -04:00
committed by GitHub
parent cf6ccddce3
commit 0d0ed1639a
15 changed files with 444 additions and 454 deletions

View File

@@ -29,18 +29,37 @@ local M = {
linked_bufnr = nil,
---@type number
unlinked_bufnr = nil,
---@type number
---@type NuiTree|nil
discussion_tree = nil,
---@type NuiTree|nil
unlinked_discussion_tree = nil,
}
---Re-fetches all discussions and re-renders the relevant view
---@param unlinked boolean
---@param all boolean|nil
M.rebuild_view = function(unlinked, all)
M.load_discussions(function()
if all then
M.rebuild_unlinked_discussion_tree()
M.rebuild_discussion_tree()
elseif unlinked then
M.rebuild_unlinked_discussion_tree()
else
M.rebuild_discussion_tree()
end
M.refresh_diagnostics_and_winbar()
end)
end
---Makes API call to get the discussion data, stores it in the state, and calls the callback
---@param callback function|nil
M.load_discussions = function(callback)
job.run_job("/mr/discussions/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data)
state.load_new_state("discussion_data", function(data)
state.DISCUSSION_DATA.discussions = u.ensure_table(data.discussions)
state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(data.unlinked_discussions)
state.DISCUSSION_DATA.emojis = u.ensure_table(data.emojis)
if type(callback) == "function" then
if callback ~= nil then
callback()
end
end)
@@ -50,7 +69,7 @@ end
M.initialize_discussions = function()
signs.setup_signs()
reviewer.set_callback_for_file_changed(function()
M.refresh_view()
M.refresh_diagnostics_and_winbar()
M.modifiable(false)
end)
reviewer.set_callback_for_reviewer_enter(function()
@@ -77,19 +96,8 @@ M.modifiable = function(bool)
end
end
---Refresh discussion data, signs, diagnostics, and winbar with new data from API
--- and rebuild the entire view
M.refresh = function(cb)
M.load_discussions(function()
M.refresh_view()
if cb ~= nil then
cb()
end
end)
end
--- Take existing data and refresh the diagnostics, the winbar, and the signs
M.refresh_view = function()
M.refresh_diagnostics_and_winbar = function()
if state.settings.discussion_signs.enabled then
diagnostics.refresh_diagnostics()
end
@@ -146,7 +154,7 @@ M.toggle = function(callback)
end
vim.schedule(function()
M.refresh_view()
M.refresh_diagnostics_and_winbar()
end)
end
@@ -219,76 +227,48 @@ M.reply = function(tree)
u.notify("Gitlab does not support replying to draft notes", vim.log.levels.WARN)
return
end
local reply_popup = Popup(u.create_popup_state("Reply", state.settings.popup.reply))
local node = tree:get_node()
local discussion_node = common.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,
miscellaneous.editable_popup_opts
)
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("/mr/reply", "POST", body, function(data)
u.notify("Sent reply!", vim.log.levels.INFO)
M.add_reply_to_tree(tree, data.note, discussion_id)
M.load_discussions()
end)
if discussion_node == nil then
u.notify("Could not get discussion root", vim.log.levels.ERROR)
return
end
local discussion_id = tostring(discussion_node.id)
local comment = require("gitlab.actions.comment")
local unlinked = tree.bufnr == M.unlinked_bufnr
local layout = comment.create_comment_layout({ ranged = false, discussion_id = discussion_id, unlinked = unlinked })
layout:mount()
end
-- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment
M.delete_comment = function(tree)
M.delete_comment = function(tree, unlinked)
vim.ui.select({ "Confirm", "Cancel" }, {
prompt = "Delete comment?",
}, function(choice)
if choice == "Confirm" then
M.send_deletion(tree)
local current_node = tree:get_node()
local note_node = common.get_note_node(tree, current_node)
local root_node = common.get_root_node(tree, current_node)
if note_node == nil or root_node == nil then
u.notify("Could not get note or root node", vim.log.levels.ERROR)
return
end
---@type integer
if M.is_draft_note(tree) then
draft_notes.confirm_delete_draft_note(note_node.id, unlinked)
else
local note_id = note_node.is_root and root_node.root_note_id or note_node.id
local comment = require("gitlab.actions.comment")
comment.confirm_delete_comment(note_id, root_node.id, unlinked)
end
end
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)
local current_node = tree:get_node()
local note_node = common.get_note_node(tree, current_node)
local root_node = common.get_root_node(tree, current_node)
if note_node == nil or root_node == nil then
u.notify("Could not get note or root node", vim.log.levels.ERROR)
return
end
---@type integer
local note_id = note_node.is_root and root_node.root_note_id or note_node.id
if root_node.is_draft then
draft_notes.send_deletion(tree)
else
local body = { discussion_id = root_node.id, note_id = tonumber(note_id) }
job.run_job("/mr/comment", "DELETE", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
if note_node.is_root then
-- Replace root node w/ current node's contents...
tree:remove_node("-" .. root_node.id)
else
tree:remove_node("-" .. note_id)
end
tree:render()
M.refresh()
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 edit_popup = Popup(u.create_popup_state("Edit Comment", state.settings.popup.edit))
@@ -316,39 +296,21 @@ M.edit_comment = function(tree, unlinked)
vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines)
-- Draft notes module handles edits for draft notes
if root_node.is_draft then
state.set_popup_keymaps(edit_popup, draft_notes.send_edits(root_node.id), nil, miscellaneous.editable_popup_opts)
else
if M.is_draft_note(tree) then
state.set_popup_keymaps(
edit_popup,
M.send_edits(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked),
draft_notes.confirm_edit_draft_note(note_node.id, unlinked),
nil,
miscellaneous.editable_popup_opts
)
else
local comment = require("gitlab.actions.comment")
state.set_popup_keymaps(
edit_popup,
comment.confirm_edit_comment(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked),
nil,
miscellaneous.editable_popup_opts
)
end
end
---This function sends the edited comment to the Go server
---@param discussion_id string
---@param note_id integer
---@param unlinked boolean
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("/mr/comment", "PATCH", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
if unlinked then
M.replace_text(state.DISCUSSION_DATA.unlinked_discussions, discussion_id, note_id, text)
M.rebuild_unlinked_discussion_tree()
else
M.replace_text(state.DISCUSSION_DATA.discussions, discussion_id, note_id, text)
M.rebuild_discussion_tree()
end
end)
end
end
@@ -374,8 +336,61 @@ M.toggle_discussion_resolved = function(tree)
job.run_job("/mr/discussions/resolve", "PUT", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
M.redraw_resolved_status(tree, note, not note.resolved)
M.refresh()
local unlinked = tree.bufnr == M.unlinked_bufnr
M.rebuild_view(unlinked)
end)
end
---Opens a popup prompting the user to choose an emoji to attach to the current node
---@param tree any
---@param unlinked boolean
M.add_emoji_to_note = function(tree, unlinked)
local node = tree:get_node()
local note_node = common.get_note_node(tree, node)
local root_node = common.get_root_node(tree, node)
local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id)
local emojis = require("gitlab.emoji").emoji_list
emoji.pick_emoji(emojis, function(name)
local body = { emoji = name, note_id = note_id }
job.run_job("/mr/awardable/note/", "POST", body, function()
u.notify("Emoji added", vim.log.levels.INFO)
M.rebuild_view(unlinked)
end)
end)
end
---Opens a popup prompting the user to choose an emoji to remove from the current node
---@param tree any
---@param unlinked boolean
M.delete_emoji_from_note = function(tree, unlinked)
local node = tree:get_node()
local note_node = common.get_note_node(tree, node)
local root_node = common.get_root_node(tree, node)
local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id)
local note_id_str = tostring(note_id)
local e = require("gitlab.emoji")
local emojis = {}
local current_emojis = state.DISCUSSION_DATA.emojis[note_id_str]
for _, current_emoji in ipairs(current_emojis) do
if state.USER.id == current_emoji.user.id then
table.insert(emojis, e.emoji_map[current_emoji.name])
end
end
emoji.pick_emoji(emojis, function(name)
local awardable_id
for _, current_emoji in ipairs(current_emojis) do
if current_emoji.name == name and current_emoji.user.id == state.USER.id then
awardable_id = current_emoji.id
break
end
end
job.run_job(string.format("/mr/awardable/note/%d/%d", note_id, awardable_id), "DELETE", nil, function()
u.notify("Emoji removed", vim.log.levels.INFO)
M.rebuild_view(unlinked)
end)
end)
end
@@ -383,31 +398,46 @@ end
-- 🌲 Helper Functions
--
---Used to collect all nodes in a tree prior to rebuilding it, so that they
---can be re-expanded before render
---@param tree any
---@return table
M.gather_expanded_node_ids = function(tree)
-- Gather all nodes for later expansion, after rebuild
local ids = {}
for id, node in pairs(tree and tree.nodes.by_id or {}) do
if node._is_expanded then
table.insert(ids, id)
end
end
return ids
end
---Rebuilds the discussion tree, which contains all comments and draft comments
---linked to specific places in the code.
M.rebuild_discussion_tree = function()
if M.linked_bufnr == nil then
return
end
local expanded_node_ids = M.gather_expanded_node_ids(M.discussion_tree)
common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr)
vim.api.nvim_buf_set_lines(M.linked_bufnr, 0, -1, false, {})
local existing_comment_nodes = discussions_tree.add_discussions_to_table(state.DISCUSSION_DATA.discussions, false)
local draft_comment_nodes = draft_notes.add_draft_notes_to_table(false)
-- Combine inline draft notes with regular comments
local all_nodes = {}
for _, draft_node in ipairs(draft_comment_nodes) do
table.insert(all_nodes, draft_node)
end
for _, node in ipairs(existing_comment_nodes) do
table.insert(all_nodes, node)
end
local all_nodes = u.join(draft_comment_nodes, existing_comment_nodes)
local discussion_tree = NuiTree({
nodes = all_nodes,
bufnr = M.linked_bufnr,
prepare_node = tree_utils.nui_tree_prepare_node,
})
-- Re-expand already expanded nodes
for _, id in ipairs(expanded_node_ids) do
tree_utils.open_node_by_id(discussion_tree, id)
end
discussion_tree:render()
M.set_tree_keymaps(discussion_tree, M.linked_bufnr, false)
@@ -423,6 +453,7 @@ M.rebuild_unlinked_discussion_tree = function()
if M.unlinked_bufnr == nil then
return
end
local expanded_node_ids = M.gather_expanded_node_ids(M.unlinked_discussion_tree)
common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr)
vim.api.nvim_buf_set_lines(M.unlinked_bufnr, 0, -1, false, {})
local existing_note_nodes =
@@ -430,20 +461,20 @@ M.rebuild_unlinked_discussion_tree = function()
local draft_comment_nodes = draft_notes.add_draft_notes_to_table(true)
-- Combine draft notes with regular notes
local all_nodes = {}
for _, draft_node in ipairs(draft_comment_nodes) do
table.insert(all_nodes, draft_node)
end
for _, node in ipairs(existing_note_nodes) do
table.insert(all_nodes, node)
end
local all_nodes = u.join(draft_comment_nodes, existing_note_nodes)
local unlinked_discussion_tree = NuiTree({
nodes = all_nodes,
bufnr = M.unlinked_bufnr,
prepare_node = tree_utils.nui_tree_prepare_node,
})
-- Re-expand already expanded nodes
for _, id in ipairs(expanded_node_ids) do
tree_utils.open_node_by_id(unlinked_discussion_tree, id)
end
unlinked_discussion_tree:render()
M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true)
M.unlinked_discussion_tree = unlinked_discussion_tree
common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr)
@@ -498,6 +529,7 @@ M.is_current_node_note = function(tree)
end
M.set_tree_keymaps = function(tree, bufnr, unlinked)
---Keybindings only relevant for linked (comment) view
if not unlinked then
vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function()
if M.is_current_node_note(tree) then
@@ -506,13 +538,19 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked)
end, { buffer = bufnr, desc = "Jump to file" })
vim.keymap.set("n", state.settings.discussion_tree.jump_to_reviewer, function()
if M.is_current_node_note(tree) then
common.jump_to_reviewer(tree, M.refresh_view)
common.jump_to_reviewer(tree, M.refresh_diagnostics_and_winbar)
end
end, { buffer = bufnr, desc = "Jump to reviewer" })
vim.keymap.set("n", state.settings.discussion_tree.toggle_tree_type, function()
M.toggle_tree_type()
end, { buffer = bufnr, desc = "Toggle tree type between `simple` and `by_file_name`" })
end
vim.keymap.set("n", state.settings.discussion_tree.refresh_data, function()
u.notify("Refreshing data...", vim.log.levels.INFO)
draft_notes.rebuild_view(unlinked, false)
end, { buffer = bufnr, desc = "Refreshes the view with Gitlab's APIs" })
vim.keymap.set("n", state.settings.discussion_tree.edit_comment, function()
if M.is_current_node_note(tree) then
M.edit_comment(tree, unlinked)
@@ -525,12 +563,11 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked)
end, { buffer = bufnr, desc = "Publish draft" })
vim.keymap.set("n", state.settings.discussion_tree.delete_comment, function()
if M.is_current_node_note(tree) then
M.delete_comment(tree)
M.delete_comment(tree, unlinked)
end
end, { buffer = bufnr, desc = "Delete comment" })
vim.keymap.set("n", state.settings.discussion_tree.toggle_draft_mode, function()
state.settings.discussion_tree.draft_mode = not state.settings.discussion_tree.draft_mode
winbar.update_winbar()
M.toggle_draft_mode()
end, { buffer = bufnr, desc = "Toggle between draft mode and live mode" })
vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved, function()
if M.is_current_node_note(tree) and not M.is_draft_note(tree) then
@@ -591,68 +628,6 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked)
emoji.init_popup(tree, bufnr)
end
---Redraws the header of a node in a tree when it's been toggled to resolved/unresolved
---@param tree NuiTree
---@param note NuiTree.Node
---@param mark_resolved boolean
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
---Replace text in discussion after note update.
---@param data Discussion[]|UnlinkedDiscussion[]
---@param discussion_id string
---@param note_id integer
---@param text string
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
end
end
end
end
end
---Given some note data, adds it to the tree and re-renders the tree
---@param tree any
---@param note any
---@param discussion_id any
M.add_reply_to_tree = function(tree, note, discussion_id)
local note_node = tree_utils.build_note(note)
note_node:expand()
tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil)
tree:render()
end
---Toggle comments tree type between "simple" and "by_file_name"
M.toggle_tree_type = function()
if state.settings.discussion_tree.tree_type == "simple" then
@@ -663,89 +638,23 @@ M.toggle_tree_type = function()
M.rebuild_discussion_tree()
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
winbar.update_winbar()
end
---Indicates whether the node under the cursor is a draft note or not
---@param tree NuiTree
---@return boolean
M.is_draft_note = function(tree)
local current_node = tree:get_node()
local note_node = common.get_note_node(tree, current_node)
if note_node and note_node.is_draft then
return true
end
local root_node = common.get_root_node(tree, current_node)
return root_node ~= nil and root_node.is_draft
end
---Opens a popup prompting the user to choose an emoji to attach to the current node
---@param tree any
---@param unlinked boolean
M.add_emoji_to_note = function(tree, unlinked)
local node = tree:get_node()
local note_node = common.get_note_node(tree, node)
local root_node = common.get_root_node(tree, node)
local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id)
local note_id_str = tostring(note_id)
local emojis = require("gitlab.emoji").emoji_list
emoji.pick_emoji(emojis, function(name)
local body = { emoji = name, note_id = note_id }
job.run_job("/mr/awardable/note/", "POST", body, function(data)
if state.DISCUSSION_DATA.emojis[note_id_str] == nil then
state.DISCUSSION_DATA.emojis[note_id_str] = {}
table.insert(state.DISCUSSION_DATA.emojis[note_id_str], data.Emoji)
else
table.insert(state.DISCUSSION_DATA.emojis[note_id_str], data.Emoji)
end
if unlinked then
M.rebuild_unlinked_discussion_tree()
else
M.rebuild_discussion_tree()
end
u.notify("Emoji added", vim.log.levels.INFO)
end)
end)
end
---Opens a popup prompting the user to choose an emoji to remove from the current node
---@param tree any
---@param unlinked boolean
M.delete_emoji_from_note = function(tree, unlinked)
local node = tree:get_node()
local note_node = common.get_note_node(tree, node)
local root_node = common.get_root_node(tree, node)
local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id)
local note_id_str = tostring(note_id)
local e = require("gitlab.emoji")
local emojis = {}
local current_emojis = state.DISCUSSION_DATA.emojis[note_id_str]
for _, current_emoji in ipairs(current_emojis) do
if state.USER.id == current_emoji.user.id then
table.insert(emojis, e.emoji_map[current_emoji.name])
end
end
emoji.pick_emoji(emojis, function(name)
local awardable_id
for _, current_emoji in ipairs(current_emojis) do
if current_emoji.name == name and current_emoji.user.id == state.USER.id then
awardable_id = current_emoji.id
break
end
end
job.run_job(string.format("/mr/awardable/note/%d/%d", note_id, awardable_id), "DELETE", nil, function(_)
local keep = {} -- Emojis to keep after deletion in the UI
for _, saved in ipairs(state.DISCUSSION_DATA.emojis[note_id_str]) do
if saved.name ~= name or saved.user.id ~= state.USER.id then
table.insert(keep, saved)
end
end
state.DISCUSSION_DATA.emojis[note_id_str] = keep
if unlinked then
M.rebuild_unlinked_discussion_tree()
else
M.rebuild_discussion_tree()
end
e.init_popup(tree, unlinked and M.unlinked_bufnr or M.linked_bufnr)
u.notify("Emoji removed", vim.log.levels.INFO)
end)
end)
end
return M