From d5510f9d9a7ee9cb7d521736dd811c77f1ad0783 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Wed, 13 Dec 2023 17:46:34 -0500 Subject: [PATCH] Winbar Support + Notes and Discussions; Help Popup + Auto-Open (#133) - Adds support for toggling between discussions and notes views - Deprecates the split view shared with both discussions and notes at the same time - Adds winbar to discussion split, with metadata about resolved and unresolved discussions - Adds help popups with information about keybindings for all views - Modifies highlights in discussion tree and default symbol for unresolved discussions This is a MINOR version bump as the default behavior of the discussion tree is changed slightly. Existing configurations should still function. --- README.md | 6 + after/syntax/gitlab.vim | 4 + lua/gitlab/actions/comment.lua | 1 + .../actions/discussions/annotations.lua | 7 + lua/gitlab/actions/discussions/init.lua | 509 ++++-------------- lua/gitlab/actions/discussions/signs.lua | 293 ++++++++++ lua/gitlab/actions/discussions/winbar.lua | 58 ++ lua/gitlab/actions/help.lua | 27 + lua/gitlab/colors.lua | 2 + lua/gitlab/reviewer/diffview.lua | 6 + lua/gitlab/state.lua | 41 +- lua/gitlab/utils/init.lua | 24 +- tests/spec/discussions_tree_spec.lua | 38 +- 13 files changed, 588 insertions(+), 428 deletions(-) create mode 100644 lua/gitlab/actions/discussions/signs.lua create mode 100644 lua/gitlab/actions/discussions/winbar.lua create mode 100644 lua/gitlab/actions/help.lua diff --git a/README.md b/README.md index cf6f896..196e359 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ require("gitlab").setup({ config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section debug = { go_request = false, go_response = false }, -- Which values to log attachment_dir = nil, -- The local directory for files (see the "summary" section) + help = "?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying exit = "", perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) @@ -132,6 +133,9 @@ require("gitlab").setup({ reply = nil, }, discussion_tree = { -- The discussion tree that holds all comments + auto_open = true, -- Automatically open when the reviewer is opened + switch_view = "T", -- Toggles between the notes and discussions views + default_view = "discussions" -- Show "discussions" or "notes" by default blacklist = {}, -- List of usernames to remove from tree (bots, CI, etc) jump_to_file = "o", -- Jump to comment location in file jump_to_reviewer = "m", -- Jump to the location in the reviewer window @@ -146,6 +150,8 @@ require("gitlab").setup({ resolved = '✓', -- Symbol to show next to resolved discussions unresolved = '✖', -- Symbol to show next to unresolved discussions tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file + winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) + -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, info = { -- Show additional fields in the summary pane enabled = true, diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index 2c6c38e..34c0d50 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -6,10 +6,14 @@ syntax match Username "@\S*" syntax match Date "\v\d+\s+\w+\s+ago" syntax match ChevronDown "" syntax match ChevronRight "" +syntax match Resolved "✓$" +syntax match Unresolved "-$" highlight link Username GitlabUsername highlight link Date GitlabDate highlight link ChevronDown GitlabChevron highlight link ChevronRight GitlabChevron +highlight link Resolved GitlabResolved +highlight link Unresolved GitlabUnresolved let b:current_syntax = "gitlab" diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 06a56f5..be8dd86 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -129,6 +129,7 @@ M.confirm_create_comment = function(text, range, unlinked) job.run_job("/comment", "POST", body, function(data) u.notify("Note created!", vim.log.levels.INFO) discussions.add_discussion({ data = data, unlinked = true }) + discussions.refresh_discussion_data() end) return end diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua index fcfa3e7..1278fca 100644 --- a/lua/gitlab/actions/discussions/annotations.lua +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -64,3 +64,10 @@ ---@class DiscussionData ---@field discussions Discussion[] ---@field unlinked_discussions UnlinkedDiscussion[] + +---@class WinbarTable +---@field name string +---@field resolvable_discussions number +---@field resolved_discussions number +---@field resolvable_notes number +---@field resolved_notes number diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 3c02c8f..735a915 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -5,417 +5,146 @@ local Split = require("nui.split") local Popup = require("nui.popup") local NuiTree = require("nui.tree") local NuiLine = require("nui.line") -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 discussions_tree = require("gitlab.actions.discussions.tree") - -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 signs = require("gitlab.actions.discussions.signs") +local winbar = require("gitlab.actions.discussions.winbar") +local help = require("gitlab.actions.help") local M = { - layout_visible = false, - layout = nil, - layout_buf = nil, + split_visible = false, + split = nil, + ---@type number + split_bufnr = nil, ---@type Discussion[] discussions = {}, ---@type UnlinkedDiscussion[] unlinked_discussions = {}, - linked_section = nil, - unlinked_section = nil, + ---@type number + linked_bufnr = nil, + ---@type number + unlinked_bufnr = nil, + ---@type number + focused_bufnr = nil, discussion_tree = nil, } ----Load the discussion data, storage them in M.discussions and M.unlinked_discussions and call +---Makes API call to get the discussion data, store it 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/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) - M.discussions = data.discussions - M.unlinked_discussions = data.unlinked_discussions + M.discussions = data.discussions ~= vim.NIL and data.discussions or {} + M.unlinked_discussions = data.unlinked_discussions ~= vim.NIL and data.unlinked_discussions or {} if type(callback) == "function" then callback(data) end 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) +---Initialize everything for discussions like setup of signs, callbacks for reviewer, etc. +M.initialize_discussions = function() + signs.setup_signs() + -- Setup callback to refresh discussion data, discussion signs and diagnostics whenever the reviewed file changes. + reviewer.set_callback_for_file_changed(M.refresh_discussion_data) + -- Setup callback to clear signs and diagnostics whenever reviewer is left. + reviewer.set_callback_for_reviewer_leave(signs.clear_signs_and_discussions) 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 u.from_iso_format_date_to_timestamp(first_note.created_at) - <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) - ) - 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 - ----Build note header from note. ----@param note Note ----@return string -M.build_note_header = function(note) - return "@" .. note.author.username .. " " .. u.time_since(note.created_at) -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 +---Refresh discussion data, signs, diagnostics, and winbar with new data from API M.refresh_discussion_data = function() M.load_discussions(function() if state.settings.discussion_sign.enabled then - M.refresh_signs() + signs.refresh_signs(M.discussions) end if state.settings.discussion_diagnostic.enabled then - M.refresh_diagnostics() + signs.refresh_diagnostics(M.discussions) + end + if M.split_visible then + local linked_is_focused = M.linked_bufnr == M.focused_bufnr + winbar.update_winbar(M.discussions, M.unlinked_discussions, linked_is_focused and "Discussions" or "Notes") 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 + if M.split_visible then + M.close() return end - local linked_section, unlinked_section, layout = M.create_layout() - M.linked_section = linked_section - M.unlinked_section = unlinked_section + local split, linked_bufnr, unlinked_bufnr = M.create_split_and_bufs() + M.linked_bufnr = linked_bufnr + M.unlinked_bufnr = unlinked_bufnr + + M.split = split + M.split_visible = true + M.split_bufnr = split.bufnr + split:mount() + M.switch_can_edit_bufs(true) + + vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { "Loading data..." }) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.split_bufnr }) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) + + local default_discussions = state.settings.discussion_tree.default_view == "discussions" + winbar.update_winbar({}, {}, default_discussions and "Discussions" or "Notes") 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) + vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { "" }) return end - layout:mount() - layout:show() + M.rebuild_discussion_tree() + M.rebuild_unlinked_discussion_tree() + M.add_empty_titles({ + { M.linked_bufnr, M.discussions, "No Discussions for this MR" }, + { M.unlinked_bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, + }) - M.layout = layout - M.layout_visible = true - M.layout_buf = layout.bufnr - state.discussion_buf = layout.bufnr - M.refresh_discussion_tree() + local default_buffer = default_discussions and M.linked_bufnr or M.unlinked_bufnr + vim.api.nvim_set_current_buf(default_buffer) + M.focused_bufnr = default_buffer + + M.switch_can_edit_bufs(false) + winbar.update_winbar(M.discussions, M.unlinked_discussions, default_discussions and "Discussions" or "Notes") if type(callback) == "function" then callback() end end) end +local switch_view_type = function() + local change_to_unlinked = M.linked_bufnr == M.focused_bufnr + local new_bufnr = change_to_unlinked and M.unlinked_bufnr or M.linked_bufnr + vim.api.nvim_set_current_buf(new_bufnr) + winbar.update_winbar(M.discussions, M.unlinked_discussions, change_to_unlinked and "Notes" or "Discussions") + M.focused_bufnr = new_bufnr +end + +-- Clears the discussion state and unmounts the split +M.close = function() + if M.split then + M.split:unmount() + end + M.split_visible = false + M.discussion_tree = nil +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 }) + local diagnostics = vim.diagnostic.get(0, { namespace = signs.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) @@ -435,11 +164,11 @@ M.move_to_discussion_tree = function() 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) + vim.api.nvim_win_set_cursor(M.split.winid, { line_number, 0 }) + vim.api.nvim_set_current_win(M.split.winid) end - if not M.layout_visible then + if not M.split_visible then M.toggle(jump_after_tree_opened) else jump_after_tree_opened() @@ -523,13 +252,14 @@ M.send_deletion = function(tree, unlinked) 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.linked_bufnr, M.discussions, "No Discussions for this MR" }, + { M.unlinked_bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, }) M.switch_can_edit_bufs(false) end + + M.refresh_discussion_data() end) end @@ -573,6 +303,7 @@ M.send_edits = function(discussion_id, note_id, unlinked) } job.run_job("/comment", "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) + M.rebuild_discussion_tree() if unlinked then M.replace_text(M.unlinked_discussions, discussion_id, note_id, text) M.rebuild_unlinked_discussion_tree() @@ -599,6 +330,7 @@ M.toggle_discussion_resolved = function(tree) job.run_job("/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_discussion_data() end) end @@ -693,36 +425,37 @@ end M.rebuild_discussion_tree = function() M.switch_can_edit_bufs(true) - vim.api.nvim_buf_set_lines(M.linked_section.bufnr, 0, -1, false, {}) + vim.api.nvim_buf_set_lines(M.linked_bufnr, 0, -1, false, {}) local discussion_tree_nodes = discussions_tree.add_discussions_to_table(M.discussions, false) local discussion_tree = - NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_section.bufnr, prepare_node = nui_tree_prepare_node }) + NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_bufnr, prepare_node = nui_tree_prepare_node }) discussion_tree:render() - M.set_tree_keymaps(discussion_tree, M.linked_section.bufnr, false) + M.set_tree_keymaps(discussion_tree, M.linked_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 }) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_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, {}) + vim.api.nvim_buf_set_lines(M.unlinked_bufnr, 0, -1, false, {}) local unlinked_discussion_tree_nodes = discussions_tree.add_discussions_to_table(M.unlinked_discussions, true) local unlinked_discussion_tree = NuiTree({ nodes = unlinked_discussion_tree_nodes, - bufnr = M.unlinked_section.bufnr, + bufnr = M.unlinked_bufnr, prepare_node = nui_tree_prepare_node, }) unlinked_discussion_tree:render() - M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_section.bufnr, true) + M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_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) + u.switch_can_edit_buf(M.unlinked_bufnr, bool) + u.switch_can_edit_buf(M.linked_bufnr, bool) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) end M.add_discussion = function(arg) @@ -732,44 +465,35 @@ M.add_discussion = function(arg) M.unlinked_discussions = {} end table.insert(M.unlinked_discussions, 1, discussion) - if M.unlinked_section ~= nil then - M.rebuild_unlinked_discussion_tree() - end + M.rebuild_unlinked_discussion_tree() 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 + M.rebuild_discussion_tree() end -M.create_layout = function() - local linked_section = Split({ enter = true }) - local unlinked_section = Split({}) - +M.create_split_and_bufs = function() 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") }) - ) + local split = Split({ + relative = relative, + position = position, + size = size, + }) - return linked_section, unlinked_section, layout + local linked_bufnr = vim.api.nvim_create_buf(true, false) + local unlinked_bufnr = vim.api.nvim_create_buf(true, false) + + return split, linked_bufnr, unlinked_bufnr end M.add_empty_titles = function(args) + M.switch_can_edit_bufs(true) local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") vim.cmd("highlight default TitleHighlight guifg=#787878") for _, section in ipairs(args) do @@ -830,7 +554,12 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) M.reply(tree) end end, { buffer = bufnr, desc = "Reply" }) - + vim.keymap.set("n", state.settings.discussion_tree.switch_view, function() + switch_view_type() + end, { buffer = bufnr, desc = "Switch view type" }) + vim.keymap.set("n", state.settings.help, function() + help.open() + end, { buffer = bufnr, desc = "Open help popup" }) if not unlinked then vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function() if M.is_current_node_note(tree) then diff --git a/lua/gitlab/actions/discussions/signs.lua b/lua/gitlab/actions/discussions/signs.lua new file mode 100644 index 0000000..b4ad158 --- /dev/null +++ b/lua/gitlab/actions/discussions/signs.lua @@ -0,0 +1,293 @@ +local state = require("gitlab.state") +local u = require("gitlab.utils") +local reviewer = require("gitlab.reviewer") +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 = {} +M.diagnostics_namespace = diagnostics_namespace + +---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[]? +local filter_discussions_for_signs_and_diagnostics = function(all_discussions) + if type(all_discussions) ~= "table" then + return + end + local file = reviewer.get_current_file() + if not file then + return + end + local discussions = {} + for _, discussion in ipairs(all_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 u.from_iso_format_date_to_timestamp(first_note.created_at) + <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) + ) + then + table.insert(discussions, discussion) + end + end + end + return discussions +end + +---Build note header from note. +---@param note Note +---@return string +local build_note_header = function(note) + return "@" .. note.author.username .. " " .. u.time_since(note.created_at) +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 + +---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(discussions) + local diagnostics = filter_discussions_for_signs_and_diagnostics(discussions) + 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(discussions) + -- Keep in mind that diagnostic line numbers use 0-based indexing while line numbers use + -- 1-based indexing + local diagnostics = filter_discussions_for_signs_and_diagnostics(discussions) + 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 .. 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 = 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 + +---Clear all signs and diagnostics +M.clear_signs_and_discussions = function() + vim.fn.sign_unplace(discussion_sign_name) + vim.diagnostic.reset(diagnostics_namespace) +end + +return M diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua new file mode 100644 index 0000000..1dd025f --- /dev/null +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -0,0 +1,58 @@ +local M = {} +local state = require("gitlab.state") + +---@param nodes Discussion[]|UnlinkedDiscussion[]|nil +local get_data = function(nodes) + if nodes == nil then + return 0, 0 + end + local total_resolvable = 0 + local total_resolved = 0 + if nodes == vim.NIL then + return "" + end + + for _, d in ipairs(nodes) do + local first_child = d.notes[1] + if first_child ~= nil then + if first_child.resolvable then + total_resolvable = total_resolvable + 1 + end + if first_child.resolved then + total_resolved = total_resolved + 1 + end + end + end + + return total_resolvable, total_resolved +end + +---@param discussions Discussion[]|nil +---@param unlinked_discussions UnlinkedDiscussion[]|nil +---@param file_name string +local function content(discussions, unlinked_discussions, file_name) + local resolvable_discussions, resolved_discussions = get_data(discussions) + local resolvable_notes, resolved_notes = get_data(unlinked_discussions) + + local t = { + name = file_name, + resolvable_discussions = resolvable_discussions, + resolved_discussions = resolved_discussions, + resolvable_notes = resolvable_notes, + resolved_notes = resolved_notes, + } + + return state.settings.discussion_tree.winbar(t) +end + +---This function sends the edited comment to the Go server +---@param discussions Discussion[] +---@param unlinked_discussions UnlinkedDiscussion[] +---@param base_title string +M.update_winbar = function(discussions, unlinked_discussions, base_title) + local d = require("gitlab.actions.discussions") + local winId = d.split.winid + vim.wo[winId].winbar = content(discussions, unlinked_discussions, base_title) +end + +return M diff --git a/lua/gitlab/actions/help.lua b/lua/gitlab/actions/help.lua new file mode 100644 index 0000000..4ba1f71 --- /dev/null +++ b/lua/gitlab/actions/help.lua @@ -0,0 +1,27 @@ +local M = {} + +local u = require("gitlab.utils") +local state = require("gitlab.state") +local Popup = require("nui.popup") + +M.open = function() + local bufnr = vim.api.nvim_get_current_buf() + local keymaps = vim.api.nvim_buf_get_keymap(bufnr, "n") + local help_content_lines = {} + for _, keymap in ipairs(keymaps) do + if keymap.desc ~= nil then + local new_line = string.format("%s: %s", keymap.lhs, keymap.desc) + table.insert(help_content_lines, new_line) + end + end + local longest_line = u.get_longest_string(help_content_lines) + local help_popup = + Popup(u.create_popup_state("Help", state.settings.popup.help, longest_line + 3, #help_content_lines + 3, 60)) + help_popup:mount() + + state.set_popup_keymaps(help_popup, "Help", nil) + local currentBuffer = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(currentBuffer, 0, #help_content_lines, false, help_content_lines) +end + +return M diff --git a/lua/gitlab/colors.lua b/lua/gitlab/colors.lua index dc3b996..44198c6 100644 --- a/lua/gitlab/colors.lua +++ b/lua/gitlab/colors.lua @@ -10,3 +10,5 @@ vim.api.nvim_set_hl(0, "GitlabChevron", u.get_colors_for_group(discussion.chevro vim.api.nvim_set_hl(0, "GitlabDirectory", u.get_colors_for_group(discussion.directory)) vim.api.nvim_set_hl(0, "GitlabDirectoryIcon", u.get_colors_for_group(discussion.directory_icon)) vim.api.nvim_set_hl(0, "GitlabFileName", u.get_colors_for_group(discussion.file_name)) +vim.api.nvim_set_hl(0, "GitlabResolved", u.get_colors_for_group(discussion.resolved)) +vim.api.nvim_set_hl(0, "GitlabUnresolved", u.get_colors_for_group(discussion.unresolved)) diff --git a/lua/gitlab/reviewer/diffview.lua b/lua/gitlab/reviewer/diffview.lua index 9b77e0c..a638f73 100644 --- a/lua/gitlab/reviewer/diffview.lua +++ b/lua/gitlab/reviewer/diffview.lua @@ -39,6 +39,12 @@ M.open = function() end end, }) + + if state.settings.discussion_tree.auto_open then + local discussions = require("gitlab.actions.discussions") + discussions.close() + discussions.toggle() + end end M.jump = function(file_name, new_line, old_line) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index a2decb1..85746c5 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -14,6 +14,7 @@ M.settings = { config_path = nil, reviewer = "diffview", attachment_dir = "", + help = "?", popup = { exit = "", perform_action = "s", @@ -26,9 +27,11 @@ M.settings = { reply = nil, comment = nil, note = nil, + help = nil, pipeline = nil, }, discussion_tree = { + auto_open = true, blacklist = {}, jump_to_file = "o", jump_to_reviewer = "m", @@ -41,8 +44,27 @@ M.settings = { position = "left", size = "20%", resolved = "✓", - unresolved = "", + unresolved = "-", tree_type = "simple", + switch_view = "T", + default_view = "discussions", + ---@param t WinbarTable + winbar = function(t) + local discussions_content = t.resolvable_discussions ~= 0 + and string.format("Discussions (%d/%d)", t.resolved_discussions, t.resolvable_discussions) + or "Discussions" + local notes_content = t.resolvable_notes ~= 0 + and string.format("Notes (%d/%d)", t.resolved_notes, t.resolvable_notes) + or "Notes" + if t.name == "Discussions" then + notes_content = "%#Comment#" .. notes_content + discussions_content = "%#Text#" .. discussions_content + else + discussions_content = "%#Comment#" .. discussions_content + notes_content = "%#Text#" .. notes_content + end + return " " .. discussions_content .. " %#Comment#| " .. notes_content + end, }, info = { enabled = true, @@ -91,13 +113,13 @@ M.settings = { display_opts = {}, -- this is dirrectly used as opts in vim.diagnostic.set, see :h vim.diagnostic.config. }, pipeline = { - created = "", + created = "", pending = "", preparing = "", scheduled = "", - running = "ﰌ", - canceled = "ﰸ", - skipped = "ﰸ", + running = "", + canceled = "", + skipped = "", success = "✓", failed = "", }, @@ -111,6 +133,8 @@ M.settings = { directory = "Directory", directory_icon = "DiffviewFolderSign", file_name = "Normal", + resolved = "DiagnosticSignOk", + unresolved = "DiagnosticSignWarn", }, }, } @@ -217,6 +241,13 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) vim.keymap.set("n", M.settings.popup.exit, function() exit(popup, opts.cb) end, { buffer = popup.bufnr, desc = "Exit popup" }) + + if action ~= "Help" then -- Don't show help on the help popup + vim.keymap.set("n", M.settings.help, function() + local help = require("gitlab.actions.help") + help.open() + end, { buffer = popup.bufnr, desc = "Open help" }) + end if action ~= nil then vim.keymap.set("n", M.settings.popup.perform_action, function() local text = u.get_buffer_text(popup.bufnr) diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 5e893ac..28073a5 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -262,17 +262,6 @@ M.split_path = function(path) return path_parts end -M.P = function(...) - local objects = {} - for i = 1, select("#", ...) do - local v = select(i, ...) - table.insert(objects, vim.inspect(v)) - end - - print(table.concat(objects, "\n")) - return ... -end - M.get_buffer_text = function(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local text = table.concat(lines, "\n") @@ -347,10 +336,10 @@ end ---Get the popup view_opts ---@param title string The string to appear on top of the popup ---@param settings table User defined popup settings ----@param width string Override default width ----@param height string Override default height +---@param width number? Override default width +---@param height number? Override default height ---@return table -M.create_popup_state = function(title, settings, width, height) +M.create_popup_state = function(title, settings, width, height, zindex) local default_settings = require("gitlab.state").settings.popup local user_settings = settings or {} local view_opts = { @@ -360,6 +349,7 @@ M.create_popup_state = function(title, settings, width, height) relative = "editor", enter = true, focusable = true, + zindex = zindex or 50, border = { style = user_settings.border or default_settings.border, text = { @@ -373,6 +363,7 @@ M.create_popup_state = function(title, settings, width, height) }, opacity = user_settings.opacity or default_settings.opacity, } + return view_opts end @@ -627,4 +618,9 @@ M.get_icon = function(filename) end end +M.basename = function(str) + local name = string.gsub(str, "(.*/)(.*)", "%2") + return name +end + return M diff --git a/tests/spec/discussions_tree_spec.lua b/tests/spec/discussions_tree_spec.lua index cd47faa..0fa7325 100644 --- a/tests/spec/discussions_tree_spec.lua +++ b/tests/spec/discussions_tree_spec.lua @@ -256,7 +256,7 @@ describe("gitlab/actions/discussions/tree.lua", function() local nodes = tree.add_discussions_to_table(discussions) assert.are.same(tree_nodes_to_table(nodes, { note = true, note_body = true }), { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -267,7 +267,7 @@ describe("gitlab/actions/discussions/tree.lua", function() }, }, { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -293,7 +293,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -312,7 +312,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -339,7 +339,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -377,7 +377,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -396,7 +396,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -415,7 +415,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -464,7 +464,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -491,7 +491,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -514,7 +514,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -550,7 +550,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -561,7 +561,7 @@ describe("gitlab/actions/discussions/tree.lua", function() }, }, { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -591,7 +591,7 @@ describe("gitlab/actions/discussions/tree.lua", function() type = "file_name", children = { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -602,7 +602,7 @@ describe("gitlab/actions/discussions/tree.lua", function() }, }, { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -626,7 +626,7 @@ describe("gitlab/actions/discussions/tree.lua", function() local nodes = tree.add_discussions_to_table(unlinked_discussions, true) assert.are.same(tree_nodes_to_table(nodes, { note = true, note_body = true }), { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -637,7 +637,7 @@ describe("gitlab/actions/discussions/tree.lua", function() }, }, { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -669,7 +669,7 @@ describe("gitlab/actions/discussions/tree.lua", function() local nodes = tree.add_discussions_to_table(unlinked_discussions, true) assert.are.same(tree_nodes_to_table(nodes, { note = true, note_body = true }), { { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { { @@ -680,7 +680,7 @@ describe("gitlab/actions/discussions/tree.lua", function() }, }, { - text = "@gitlab.username 5 days ago ", + text = "@gitlab.username 5 days ago -", type = "note", children = { {