-- This module contains code shared between at least two modules. This includes -- actions common to multiple tree types, as well as general utility functions -- that are specific to actions (like jumping to a file or opening a URL) local List = require("gitlab.utils.list") local u = require("gitlab.utils") local reviewer = require("gitlab.reviewer") local indicators_common = require("gitlab.indicators.common") local state = require("gitlab.state") local M = {} ---Build note header from note ---@param note Note|DraftNote ---@return string M.build_note_header = function(note) if note.note then return "@" .. state.USER.username .. " " .. state.settings.discussion_tree.draft end local time = state.settings.discussion_tree.relative_date and u.time_since(note.created_at) or u.format_to_local(note.created_at, vim.fn.strftime("%z")) return "@" .. note.author.username .. " " .. time end M.switch_can_edit_bufs = function(bool, ...) local bufnrs = { ... } ---@param v integer for _, v in ipairs(bufnrs) do u.switch_can_edit_buf(v, bool) end end ---Takes in a chunk of text separated by new line characters and returns a lua table ---@param content string ---@return table M.build_content = function(content) local description_lines = u.lines_into_table(content) table.insert(description_lines, "") return description_lines end M.add_empty_titles = function() local draft_notes = require("gitlab.actions.draft_notes") local discussions = require("gitlab.actions.discussions") local linked, unlinked, drafts = List.new(u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions)), List.new(u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.unlinked_discussions)), List.new(u.ensure_table(state.DRAFT_NOTES)) local position_drafts = drafts:filter(function(note) return draft_notes.has_position(note) end) local non_positioned_drafts = drafts:filter(function(note) return not draft_notes.has_position(note) end) local fields = { { bufnr = discussions.linked_bufnr, count = #linked + #position_drafts, title = "No Discussions for this MR", }, { bufnr = discussions.unlinked_bufnr, count = #unlinked + #non_positioned_drafts, title = "No Notes (Unlinked Discussions) for this MR", }, } for _, v in ipairs(fields) do if v.bufnr ~= nil then M.switch_can_edit_bufs(true, v.bufnr) local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") vim.cmd("highlight default TitleHighlight guifg=#787878") -- Set empty title if applicable if v.count == 0 then vim.api.nvim_buf_set_lines(v.bufnr, 0, 1, false, { v.title }) local linnr = 1 vim.api.nvim_buf_set_extmark( v.bufnr, ns_id, linnr - 1, 0, { end_row = linnr - 1, end_col = string.len(v.title), hl_group = "TitleHighlight" } ) end M.switch_can_edit_bufs(false, v.bufnr) end end end ---@param tree NuiTree M.get_url = function(tree) local current_node = tree:get_node() local note_node = M.get_note_node(tree, current_node) if note_node == nil then return end local url = note_node.url if url == nil then u.notify("Could not get URL of note", vim.log.levels.ERROR) return end return url end ---@param tree NuiTree M.open_in_browser = function(tree) local url = M.get_url(tree) if url ~= nil then u.open_in_browser(url) end end ---@param tree NuiTree M.copy_node_url = function(tree) local url = M.get_url(tree) if url ~= nil then vim.fn.setreg("+", url) u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) end end -- For developers! M.print_node = function(tree) local current_node = tree:get_node() vim.print(current_node) end ---Check if type of node is note or note body ---@param node NuiTree.Node? ---@return boolean M.is_node_note = function(node) if node and (node.type == "note_body" or node.type == "note") then return true else return false end end ---Get root node ---@param tree NuiTree ---@param node NuiTree.Node? ---@return NuiTree.Node? M.get_root_node = function(tree, node) if not node then return nil end if node.type == "note_body" or node.type == "note" and not node.is_root then local parent_id = node:get_parent_id() return M.get_root_node(tree, tree:get_node(parent_id)) elseif node.is_root then return node end end ---Get note node ---@param tree NuiTree ---@param node NuiTree.Node? ---@return NuiTree.Node? M.get_note_node = function(tree, node) if not node then return nil end if node.type == "note_body" 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)) elseif node.type == "note" then return node end end ---Takes a node and returns the line where the note is positioned in the new SHA. If ---the line is not in the new SHA, returns nil ---@param node NuiTree.Node ---@return number|nil local function get_new_line(node) ---@type GitlabLineRange|nil local range = node.range if range == nil then return node.new_line end local _, new_start_line = indicators_common.parse_line_code(range.start.line_code) return new_start_line end ---Takes a node and returns the line where the note is positioned in the old SHA. If ---the line is not in the old SHA, returns nil ---@param node NuiTree.Node ---@return number|nil local function get_old_line(node) ---@type GitlabLineRange|nil local range = node.range if range == nil then return node.old_line end local old_start_line, _ = indicators_common.parse_line_code(range.start.line_code) return old_start_line end ---@param id string|integer ---@return integer|nil line_number ---@return boolean is_new_sha True if line number refers to NEW SHA M.get_line_number = function(id) ---@type Discussion|DraftNote|nil local d_or_n d_or_n = List.new(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions or {}):find(function(d) return d.id == id end) or List.new(state.DRAFT_NOTES or {}):find(function(d) return d.id == id end) if d_or_n == nil then return nil, true end local first_note = indicators_common.get_first_note(d_or_n) local is_new_sha = indicators_common.is_new_sha(d_or_n) return ((is_new_sha and first_note.position.new_line or first_note.position.old_line) or 1), is_new_sha end ---Return the start and end line numbers for the note range. The range is calculated from the line ---codes but the position itself is based on either the `new_line` or `old_line`. ---@param old_line integer|nil The line number in the OLD version ---@param new_line integer|nil The line number in the NEW version ---@param start_line_code string The line code for the start of the range ---@param end_line_code string The line code for the end of the range ---@return integer start_line ---@return integer end_line ---@return boolean is_new_sha True if line range refers to NEW SHA M.get_line_numbers_for_range = function(old_line, new_line, start_line_code, end_line_code) local old_start_line, new_start_line = indicators_common.parse_line_code(start_line_code) local old_end_line, new_end_line = indicators_common.parse_line_code(end_line_code) if old_line ~= nil and old_start_line ~= 0 then local range = old_end_line - old_start_line return (old_line - range), old_line, false elseif new_line ~= nil then local range = new_end_line - new_start_line -- Force start_line to be greater than 0 local start_line = (new_line - range > 0) and (new_line - range) or 1 return start_line, new_line, true else u.notify("Error getting new or old line for range", vim.log.levels.ERROR) return 1, 1, false end end ---@param root_node NuiTree.Node ---@return integer|nil line_number ---@return boolean is_new_sha True if line number refers to NEW SHA M.get_line_number_from_node = function(root_node) if root_node.range then local line_number, _, is_new_sha = M.get_line_numbers_for_range( root_node.old_line, root_node.new_line, root_node.range.start.line_code, root_node.range["end"].line_code ) return line_number, is_new_sha else return M.get_line_number(root_node.id) end end -- This function (settings.keymaps.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 node = tree:get_node() local root_node = M.get_root_node(tree, node) if root_node == nil then u.notify("Could not get discussion node", vim.log.levels.ERROR) return end local line_number, is_new_sha = M.get_line_number_from_node(root_node) if line_number == nil then u.notify("Could not get line number", vim.log.levels.ERROR) return end reviewer.jump(root_node.file_name, root_node.old_file_name, line_number, is_new_sha) end -- This function (settings.keymaps.discussion_tree.jump_to_file) will jump to the file changed in a new tab M.jump_to_file = function(tree) local node = tree:get_node() local root_node = M.get_root_node(tree, node) if root_node == nil then u.notify("Could not get discussion node", vim.log.levels.ERROR) return end if root_node.file_name == nil then u.notify("This comment was not left on a particular location", vim.log.levels.WARN) return end if vim.fn.filereadable(root_node.file_name) == 0 then u.notify( string.format("The file %s for which the comment was made doesn't exist in HEAD.", root_node.file_name), vim.log.levels.WARN ) return end vim.cmd.tabnew() local line_number = get_new_line(root_node) or get_old_line(root_node) if line_number == nil or line_number == 0 then line_number = 1 end local bufnr = vim.fn.bufnr(root_node.file_name) if bufnr ~= -1 then vim.cmd("buffer " .. bufnr) vim.api.nvim_win_set_cursor(0, { line_number, 0 }) return end -- If buffer is not already open, open it vim.cmd("edit " .. root_node.file_name) vim.api.nvim_win_set_cursor(0, { line_number, 0 }) end return M