diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 73f5ad13..5d16155a 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -8,6 +8,11 @@ on: - 'cmd/**' # Ignore changes to the Lua code - 'go.sum' - 'go.mod' + +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + jobs: go_lint: name: Lint Go 💅 diff --git a/.github/workflows/lua.yaml b/.github/workflows/lua.yaml index a8998468..9ecfa484 100644 --- a/.github/workflows/lua.yaml +++ b/.github/workflows/lua.yaml @@ -45,12 +45,15 @@ jobs: with: neovim: true version: ${{ matrix.nvim_version }} - - name: Install luajit - uses: leafo/gh-actions-lua@v10 + - uses: leafo/gh-actions-lua@v11 with: luaVersion: "luajit-openresty" - - name: Install luarocks - uses: leafo/gh-actions-luarocks@v4 + - uses: hishamhm/gh-actions-luarocks@master + with: + luaRocksVersion: "3.12.0" + - name: build + run: | + luarocks install busted - name: Run tests shell: bash run: | diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index d19182c5..5922f296 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -5,10 +5,11 @@ endif let expanders = '^\s*\%(' . g:gitlab_discussion_tree_expander_open . '\|' . g:gitlab_discussion_tree_expander_closed . '\)' let username = '@[a-zA-Z0-9.]\+' -" Covers times like '14 days ago', 'just now', as well as 'October 3, 2024' +" Covers times like '14 days ago', 'just now', as well as 'October 3, 2024', and '02/28/2025 at 00:50' let time_ago = '\d\+ \w\+ ago' let formatted_date = '\w\+ \{1,2}\d\{1,2}, \d\{4}' -let date = '\%(' . time_ago . '\|' . formatted_date . '\|just now\)' +let absolute_time = '\d\{2}/\d\{2}/\d\{4} at \d\{2}:\d\{2}' +let date = '\%(' . time_ago . '\|' . formatted_date . '\|' . absolute_time . '\|just now\)' let published = date . ' \%(' . g:gitlab_discussion_tree_resolved . '\|' . g:gitlab_discussion_tree_unresolved . '\|' . g:gitlab_discussion_tree_unlinked . '\)\?' let state = ' \%(' . published . '\|' . g:gitlab_discussion_tree_draft . '\)' diff --git a/cmd/app/client.go b/cmd/app/client.go index a599397c..c68bedad 100644 --- a/cmd/app/client.go +++ b/cmd/app/client.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" "github.com/hashicorp/go-retryablehttp" @@ -66,10 +67,18 @@ func NewClient() (*Client, error) { }, } + if proxy := pluginOptions.ConnectionSettings.Proxy; proxy != "" { + u, err := url.Parse(proxy) + if err != nil { + return nil, fmt.Errorf("parse proxy url: %w", err) + } + tr.Proxy = http.ProxyURL(u) + } + retryClient := retryablehttp.NewClient() retryClient.HTTPClient.Transport = tr - retryClient.RetryMax = 0 gitlabOptions = append(gitlabOptions, gitlab.WithHTTPClient(retryClient.HTTPClient)) + gitlabOptions = append(gitlabOptions, gitlab.WithoutRetries()) client, err := gitlab.NewClient(pluginOptions.AuthToken, gitlabOptions...) @@ -99,11 +108,11 @@ func InitProjectSettings(c *Client, gitInfo git.GitData) (*ProjectInfo, error) { project, _, err := c.GetProject(gitInfo.ProjectPath(), &opt) if err != nil { - return nil, fmt.Errorf(fmt.Sprintf("Error getting project at %s", gitInfo.RemoteUrl), err) + return nil, fmt.Errorf("error getting project at %s: %w", gitInfo.RemoteUrl, err) } if project == nil { - return nil, fmt.Errorf(fmt.Sprintf("Could not find project at %s", gitInfo.RemoteUrl), err) + return nil, fmt.Errorf("could not find project at %s", gitInfo.RemoteUrl) } projectId := fmt.Sprint(project.ID) diff --git a/cmd/app/comment_helpers.go b/cmd/app/comment_helpers.go index 8b74ccd5..d6548bd5 100644 --- a/cmd/app/comment_helpers.go +++ b/cmd/app/comment_helpers.go @@ -42,13 +42,19 @@ type RequestWithPosition interface { func buildCommentPosition(commentWithPositionData RequestWithPosition) *gitlab.PositionOptions { positionData := commentWithPositionData.GetPositionData() + // If the file has been renamed, then this is a relevant part of the payload + oldFileName := positionData.OldFileName + if oldFileName == "" { + oldFileName = positionData.FileName + } + opt := &gitlab.PositionOptions{ PositionType: &positionData.Type, StartSHA: &positionData.StartCommitSHA, HeadSHA: &positionData.HeadCommitSHA, BaseSHA: &positionData.BaseCommitSHA, NewPath: &positionData.FileName, - OldPath: &positionData.OldFileName, + OldPath: &oldFileName, NewLine: positionData.NewLine, OldLine: positionData.OldLine, } diff --git a/cmd/app/config.go b/cmd/app/config.go index dc40dfe2..9d8bd0ac 100644 --- a/cmd/app/config.go +++ b/cmd/app/config.go @@ -13,6 +13,7 @@ type PluginOptions struct { } `json:"debug"` ChosenMrIID int `json:"chosen_mr_iid"` ConnectionSettings struct { + Proxy string `json:"proxy"` Insecure bool `json:"insecure"` Remote string `json:"remote"` } `json:"connection_settings"` diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index e1c38bcc..0e479361 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -161,6 +161,7 @@ you call this function with no values the defaults will be used: }, }, connection_settings = { + proxy = "", -- Configure a proxy URL to use when connecting to GitLab. Supports URL schemes: http, https, socks5 insecure = false, -- Like curl's --insecure option, ignore bad x509 certificates on connection remote = "origin", -- The default remote that your MRs target }, @@ -213,6 +214,7 @@ you call this function with no values the defaults will be used: switch_view = "c", -- Toggle between the notes and discussions views toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" publish_draft = "P", -- Publish the currently focused note/comment + toggle_date_format = "dt", -- Toggle between date formats: relative (e.g., "5 days ago", "just now", "October 13, 2024" for dates more than a month ago) and absolute (e.g., "03/01/2024 at 11:43") toggle_draft_mode = "D", -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) toggle_sort_method = "st", -- Toggle whether discussions are sorted by the "latest_reply", or by "original_comment", see `:h gitlab.nvim.toggle_sort_method` toggle_node = "t", -- Open or close the discussion @@ -221,11 +223,22 @@ you call this function with no values the defaults will be used: toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions refresh_data = "", -- Refresh the data in the view by hitting Gitlab's APIs again print_node = "p", -- Print the current node (for debugging) + edit_suggestion = "se", -- Edit comment with suggestion preview in a new tab + reply_with_suggestion = "sr", -- Reply to comment with a suggestion preview in a new tab + apply_suggestion = "sa", -- Apply the suggestion to the local file with a preview in a new tab + }, + suggestion_preview = { + apply_changes = "ZZ", -- Close suggestion preview tab, and post comment to Gitlab (discarding changes to local file). In "apply mode", accept suggestion, commit changes, then push to remote and resolve thread + discard_changes = "ZQ", -- Close suggestion preview tab and discard changes in local file + attach_file = "ZA", -- Attach a file from the `settings.attachment_dir` + apply_changes_locally = "Zz", -- Only in "apply mode", close suggestion preview tab and write suggestion buffer to local file (no changes posted to Gitlab) + paste_default_suggestion = "glS", -- Paste the default suggestion below the cursor (overrides default "glS" (start review) keybinding for the "Note" buffer) }, reviewer = { disable_all = false, -- Disable all default mappings for the reviewer windows create_comment = "c", -- Create a comment for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line create_suggestion = "s", -- Create a suggestion for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line + create_suggestion_with_preview = "S", -- In a new tab create a suggestion with a diff preview for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line move_to_discussion_tree = "a", -- Jump to the comment in the discussion tree }, }, @@ -267,6 +280,7 @@ you call this function with no values the defaults will be used: draft = "✎", -- Symbol to show next to draft comments/notes tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file draft_mode = false, -- Whether comments are posted as drafts as part of a review + relative_date = true, -- Whether to show relative time like "5 days ago" or absolute time like "03/01/2025 at 01:43" 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. }, @@ -536,9 +550,11 @@ emojis that you have responded with. UPLOADING FILES *gitlab.nvim.uploading-files* To attach a file to an MR description, reply, comment, and so forth use the -`keymaps.popup.perform_linewise_action` keybinding when the popup is open. -This will open a picker that will look for files in the directory you specify -in the `settings.attachment_dir` folder (this must be an absolute path). +`keymaps.popup.perform_linewise_action` keybinding when the popup is open (or +the `keymaps.suggestion_preview.attach_file` in the comment buffer of the +suggestion preview). This will open a picker that will look for files in the +directory you specify in the `settings.attachment_dir` folder (this must be an +absolute path). When you have picked the file, it will be added to the current buffer at the current line. diff --git a/lua-test.sh b/lua-test.sh index 63513751..6cf83dc9 100755 --- a/lua-test.sh +++ b/lua-test.sh @@ -1,59 +1,44 @@ #!/usr/bin/env bash # # Setup and run tests for lua part of gitlab.nvim. -# -# In order to run tests you need to have `luarocks` and `git` installed. This script will check if -# environment is already setup, if not it will initialize current directory with `luarocks`, -# install `busted` framework and download plugin dependencies. -# +# Requires `luarocks`, `git`, and `nvim` installed. # -set -e -LUA_VERSION="5.1" +set -euo pipefail + PLUGINS_FOLDER="tests/plugins" PLUGINS=( - "https://github.com/MunifTanjim/nui.nvim" - "https://github.com/nvim-lua/plenary.nvim" - "https://github.com/sindrets/diffview.nvim" + "https://github.com/MunifTanjim/nui.nvim" + "https://github.com/nvim-lua/plenary.nvim" + "https://github.com/sindrets/diffview.nvim" ) -if ! command -v luarocks > /dev/null 2>&1; then - echo "You need to have luarocks installed in order to run tests." - exit 1 -fi - -if ! command -v git > /dev/null 2>&1; then - echo "You need to have git installed in order to run tests." - exit 1 +if ! command -v luarocks >/dev/null 2>&1; then + echo "Error: luarocks not found. Please install LuaRocks." >&2 + exit 1 fi -if ! luarocks --lua-version=$LUA_VERSION which busted > /dev/null 2>&1; then - echo "Installing busted." - luarocks init - luarocks config --scope project lua_version "$LUA_VERSION" - luarocks install --lua-version="$LUA_VERSION" busted +if ! command -v git >/dev/null 2>&1; then + echo "Error: git not found. Please install Git." >&2 + exit 1 fi -for arg in "$@"; do -if [[ $arg =~ "--coverage" ]] && ! luarocks --lua-version=$LUA_VERSION which luacov > /dev/null 2>&1; then - luarocks install --lua-version="$LUA_VERSION" luacov - # lcov reporter for luacov - lcov format is supported by `nvim-coverage` - luarocks install --lua-version="$LUA_VERSION" luacov-reporter-lcov +if ! command -v nvim >/dev/null 2>&1; then + echo "Error: nvim not found. Please install Neovim." >&2 + exit 1 fi -done +# Clone test plugin dependencies +mkdir -p "$PLUGINS_FOLDER" for plugin in "${PLUGINS[@]}"; do - plugin_name=${plugin##*/} - plugin_folder="$PLUGINS_FOLDER/$plugin_name" - - # Check if plugin was already downloaded - if [[ -d "$plugin_folder/.git" ]]; then - # We could also try to pull here but I am not sure if that wouldn't slow down tests too much. - continue - fi - + plugin_name="${plugin##*/}" + plugin_folder="$PLUGINS_FOLDER/$plugin_name" + if [[ ! -d "$plugin_folder/.git" ]]; then + echo "Cloning $plugin..." git clone --depth 1 "$plugin" "$plugin_folder" - + fi done -nvim -u NONE -U NONE -N -i NONE -l tests/init.lua "$@" +# Run tests +echo "Running tests with Neovim..." +nvim -u NONE -U NONE -N -i NONE -l tests/init.lua "$@" diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 2c3ae1c7..71666737 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -21,18 +21,25 @@ local M = { comment_popup = nil, } +---Decide if the comment is a draft based on the draft popup field. +---@return boolean|nil is_draft True if the draft popup exists and the string it contains converts to `true`. +local get_draft_value_from_popup = function() + local buf_is_valid = M.draft_popup and M.draft_popup.bufnr and vim.api.nvim_buf_is_valid(M.draft_popup.bufnr) + return buf_is_valid and u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr)) +end + ---Fires the API that sends the comment data to the Go server, called when you "confirm" creation ---via the M.settings.keymaps.popup.perform_action keybinding ---@param text string comment text ---@param unlinked boolean if true, the comment is not linked to a line ---@param discussion_id string | nil The ID of the discussion to which the reply is responding, nil if not a reply -local confirm_create_comment = function(text, unlinked, discussion_id) +M.confirm_create_comment = function(text, unlinked, discussion_id) if text == nil then u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) return end - local is_draft = M.draft_popup and u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr)) + local is_draft = get_draft_value_from_popup() or state.settings.discussion_tree.draft_mode -- Creating a normal reply to a discussion if discussion_id ~= nil and not is_draft then @@ -153,9 +160,9 @@ M.create_comment_layout = function(opts) title = "Note" user_settings = popup_settings.note else - -- TODO: investigate why `old_file_name` is in fact the new name for renamed files! - local file_name = M.location.reviewer_data.old_file_name ~= "" and M.location.reviewer_data.old_file_name - or M.location.reviewer_data.file_name + local file_name = (M.location.reviewer_data.new_sha_focused or M.location.reviewer_data.old_file_name == "") + and M.location.reviewer_data.file_name + or M.location.reviewer_data.old_file_name title = popup.create_title("Comment", file_name, M.location.visual_range.start_line, M.location.visual_range.end_line) user_settings = popup_settings.comment @@ -188,13 +195,13 @@ M.create_comment_layout = function(opts) ---Keybinding for focus on draft section popup.set_popup_keymaps(M.draft_popup, function() local text = u.get_buffer_text(M.comment_popup.bufnr) - confirm_create_comment(text, unlinked, opts.discussion_id) + M.confirm_create_comment(text, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(current_win) end, miscellaneous.toggle_bool, popup.non_editable_popup_opts) ---Keybinding for focus on text section popup.set_popup_keymaps(M.comment_popup, function(text) - confirm_create_comment(text, unlinked, opts.discussion_id) + M.confirm_create_comment(text, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(current_win) end, miscellaneous.attach_file, popup.editable_popup_opts) @@ -295,6 +302,33 @@ M.create_comment_suggestion = function() end) end +--- This function will create a new tab with a suggestion preview for the changed/updated line in +--- the current MR. +M.create_comment_with_suggestion = function() + M.location = Location.new() + if not M.can_create_comment(true) then + u.press_escape() + return + end + + local old_file_name = M.location.reviewer_data.old_file_name ~= "" and M.location.reviewer_data.old_file_name + or M.location.reviewer_data.file_name + local is_new_sha = M.location.reviewer_data.new_sha_focused + + ---@type ShowPreviewOpts + local opts = { + old_file_name = old_file_name, + new_file_name = M.location.reviewer_data.file_name, + start_line = M.location.visual_range.start_line, + end_line = M.location.visual_range.end_line, + is_new_sha = is_new_sha, + revision = is_new_sha and "HEAD" or require("gitlab.state").INFO.target_branch, + note_header = "comment", + comment_type = "new", + } + require("gitlab.actions.suggestions").show_preview(opts) +end + ---Returns true if it's possible to create an Inline Comment ---@param must_be_visual boolean True if current mode must be visual ---@return boolean diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 6ad224db..7810c347 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -15,7 +15,9 @@ M.build_note_header = function(note) if note.note then return "@" .. state.USER.username .. " " .. state.settings.discussion_tree.draft end - return "@" .. note.author.username .. " " .. u.time_since(note.created_at) + 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, ...) @@ -172,11 +174,32 @@ M.get_note_node = function(tree, node) end end +---Gather all lines from immediate children that aren't note nodes +---@param tree NuiTree +---@return string[] List of individual note lines +M.get_note_lines = function(tree) + local current_node = tree:get_node() + local note_node = M.get_note_node(tree, current_node) + if note_node == nil then + u.notify("Could not get note node", vim.log.levels.ERROR) + return {} + end + local lines = List.new(note_node:get_child_ids()):reduce(function(agg, child_id) + local child_node = tree:get_node(child_id) + if child_node ~= nil and not child_node:has_children() then + local line = tree:get_node(child_id).text + table.insert(agg, line) + end + return agg + end, {}) + return lines +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) +M.get_new_line = function(node) ---@type GitlabLineRange|nil local range = node.range if range == nil then @@ -240,7 +263,9 @@ M.get_line_numbers_for_range = function(old_line, new_line, start_line_code, end return (old_line - range), old_line, false elseif new_line ~= nil then local range = new_end_line - new_start_line - return (new_line - range), new_line, true + -- 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 @@ -250,17 +275,19 @@ end ---@param root_node NuiTree.Node ---@return integer|nil line_number ---@return boolean is_new_sha True if line number refers to NEW SHA +---@return integer|nil end_line 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( + local line_number, end_line, 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 + return line_number, is_new_sha, end_line else - return M.get_line_number(root_node.id) + local start_line, is_new_sha = M.get_line_number(root_node.id) + return start_line, is_new_sha, start_line end end @@ -300,7 +327,7 @@ M.jump_to_file = function(tree) return end vim.cmd.tabnew() - local line_number = get_new_line(root_node) or get_old_line(root_node) + local line_number = M.get_new_line(root_node) or get_old_line(root_node) if line_number == nil or line_number == 0 then line_number = 1 end @@ -316,4 +343,31 @@ M.jump_to_file = function(tree) vim.api.nvim_win_set_cursor(0, { line_number, 0 }) end +---Determine whether commented line has changed since making the comment. +---@param tree NuiTree The current discussion tree instance. +---@param note_node NuiTree.Node The main node of the note containing the note author etc. +---@return boolean line_changed True if any of the notes in the thread is a system note starting with "changed this line". +M.commented_line_has_changed = function(tree, note_node) + local line_changed = List.new(note_node:get_child_ids()):includes(function(child_id) + local child_node = tree:get_node(child_id) + if child_node == nil then + return false + end + + -- Inspect note bodies or recourse to child notes. + if child_node.type == "note_body" then + local line = tree:get_node(child_id).text + if string.match(line, "^changed this line") and note_node.system then + return true + end + elseif child_node.type == "note" and M.commented_line_has_changed(tree, child_node) then + return true + end + + return false + end) + + return line_changed +end + return M diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 59e381fa..9e059249 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -11,7 +11,6 @@ local popup = require("gitlab.popup") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") local common = require("gitlab.actions.common") -local List = require("gitlab.utils.list") local tree_utils = require("gitlab.actions.discussions.tree") local discussions_tree = require("gitlab.actions.discussions.tree") local draft_notes = require("gitlab.actions.draft_notes") @@ -240,12 +239,85 @@ M.reply = function(tree) discussion_id = discussion_id, unlinked = unlinked, reply = true, + -- TODO: use discussion_node.old_file_name for comments on unchanged lines in renamed files file_name = discussion_node.file_name, }) layout:mount() end +---Open a new tab with a suggestion preview. +---@param tree NuiTree The current discussion tree instance. +---@param action "reply"|"edit"|"apply" Reply to the current thread, edit the current comment or apply the suggestion to local file. +M.suggestion_preview = function(tree, action) + local is_draft = M.is_draft_note(tree) + if action == "reply" and is_draft then + u.notify("Gitlab does not support replying to draft notes", vim.log.levels.WARN) + return + end + + local current_node = tree:get_node() + local root_node = common.get_root_node(tree, current_node) + local note_node = common.get_note_node(tree, current_node) + + -- Return early if note info is missing + if root_node == nil or note_node == nil then + u.notify("Couldn't get root node or note node", vim.log.levels.ERROR) + return + end + local note_node_id = tonumber(note_node.is_root and note_node.root_note_id or note_node.id) + if note_node_id == nil then + u.notify("Couldn't get comment id", vim.log.levels.ERROR) + return + end + + -- Return early if comment position is missing + local start_line, is_new_sha, end_line = common.get_line_number_from_node(root_node) + if start_line == nil or end_line == nil then + u.notify("Couldn't get comment range. Can't create suggestion preview", vim.log.levels.ERROR) + return + end + + -- Override reviewer values when local-applying a suggestion that was made on the OLD version + if action == "apply" and not is_new_sha then + local range = end_line - start_line + start_line = common.get_new_line(root_node) + + if start_line == nil then + u.notify("Couldn't get position in new version. Can't create suggestion preview", vim.log.levels.ERROR) + return + end + + end_line = start_line + range + is_new_sha = true + end + + -- Get values for preview depending on whether comment is on OLD or NEW version + local revision + if is_new_sha then + revision = common.commented_line_has_changed(tree, root_node) and root_node.head_sha or "HEAD" + else + revision = root_node.base_sha + end + + ---@type ShowPreviewOpts + local opts = { + old_file_name = root_node.old_file_name, + new_file_name = root_node.file_name, + start_line = start_line, + end_line = end_line, + is_new_sha = is_new_sha, + revision = revision, + note_header = note_node.text, + comment_type = is_draft and "draft" or action, + note_lines = action ~= "reply" and common.get_note_lines(tree) or nil, + root_node_id = root_node.id, + note_node_id = note_node_id, + tree = tree, + } + require("gitlab.actions.suggestions").show_preview(opts) +end + -- This function (settings.keymaps.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" }, { @@ -289,15 +361,7 @@ M.edit_comment = function(tree, unlinked) edit_popup:mount() - -- Gather all lines from immediate children that aren't note nodes - local lines = List.new(note_node:get_child_ids()):reduce(function(agg, child_id) - 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(agg, line) - end - return agg - end, {}) + local lines = common.get_note_lines(tree) local currentBuffer = vim.api.nvim_get_current_buf() vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) @@ -322,7 +386,9 @@ M.edit_comment = function(tree, unlinked) end -- This function (settings.keymaps.discussion_tree.toggle_discussion_resolved) will toggle the resolved status of the current discussion and send the change to the Go server -M.toggle_discussion_resolved = function(tree) +---@param tree NuiTree +---@param override boolean|nil If not nil, set resolved to `override` value instead of toggling. +M.toggle_discussion_resolved = function(tree, override) local note = tree:get_node() if note == nil then return @@ -336,9 +402,16 @@ M.toggle_discussion_resolved = function(tree) return end + local resolved + if override ~= nil then + resolved = override + else + resolved = not note.resolved + end + local body = { discussion_id = note.id, - resolved = not note.resolved, + resolved = resolved, } job.run_job("/mr/discussions/resolve", "PUT", body, function(data) @@ -593,6 +666,34 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) nowait = keymaps.discussion_tree.toggle_tree_type_nowait, }) end + + if keymaps.discussion_tree.edit_suggestion then + vim.keymap.set("n", keymaps.discussion_tree.edit_suggestion, function() + if M.is_current_node_note(tree) then + M.suggestion_preview(tree, "edit") + end + end, { buffer = bufnr, desc = "Edit suggestion", nowait = keymaps.discussion_tree.edit_suggestion_nowait }) + end + + if keymaps.discussion_tree.apply_suggestion then + vim.keymap.set("n", keymaps.discussion_tree.apply_suggestion, function() + if M.is_current_node_note(tree) then + M.suggestion_preview(tree, "apply") + end + end, { buffer = bufnr, desc = "Apply suggestion", nowait = keymaps.discussion_tree.apply_suggestion_nowait }) + end + + if keymaps.discussion_tree.reply_with_suggestion then + vim.keymap.set("n", keymaps.discussion_tree.reply_with_suggestion, function() + if M.is_current_node_note(tree) then + M.suggestion_preview(tree, "reply") + end + end, { + buffer = bufnr, + desc = "Reply with suggestion", + nowait = keymaps.discussion_tree.reply_with_suggestion_nowait, + }) + end end if keymaps.discussion_tree.refresh_data then @@ -649,6 +750,16 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) }) end + if keymaps.discussion_tree.toggle_date_format then + vim.keymap.set("n", keymaps.discussion_tree.toggle_date_format, function() + M.toggle_date_format() + end, { + buffer = bufnr, + desc = "Toggle date format", + nowait = keymaps.discussion_tree.toggle_date_format_nowait, + }) + end + if keymaps.discussion_tree.toggle_resolved then vim.keymap.set("n", keymaps.discussion_tree.toggle_resolved, function() if M.is_current_node_note(tree) and not M.is_draft_note(tree) then @@ -725,7 +836,7 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) if keymaps.help then vim.keymap.set("n", keymaps.help, function() - help.open() + help.open({ discussion_tree = true }) end, { buffer = bufnr, desc = "Open help popup", nowait = keymaps.help_nowait }) end @@ -795,6 +906,10 @@ 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 + vim.api.nvim_exec_autocmds("User", { + pattern = "GitlabDraftModeToggled", + data = { draft_mode = state.settings.discussion_tree.draft_mode }, + }) end ---Toggle between sorting by "original comment" (oldest at the top) or "latest reply" (newest at the @@ -809,6 +924,13 @@ M.toggle_sort_method = function() M.rebuild_view(false, true) end +---Toggle between displaying relative time (e.g., "5 days ago") and absolute time (e.g., "04/10/2025 at 22:49") +M.toggle_date_format = function() + state.settings.discussion_tree.relative_date = not state.settings.discussion_tree.relative_date + M.rebuild_unlinked_discussion_tree() + M.rebuild_discussion_tree() +end + ---Indicates whether the node under the cursor is a draft note or not ---@param tree NuiTree ---@return boolean diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index e4f192ba..35a4816f 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -39,7 +39,10 @@ M.add_discussions_to_table = function(items, unlinked) local resolved = false local root_new_line = nil local root_old_line = nil + local root_head_sha = nil + local root_base_sha = nil local root_url + local system = false for j, note in ipairs(discussion.notes) do if j == 1 then @@ -48,12 +51,15 @@ M.add_discussions_to_table = function(items, unlinked) root_old_file_name = (type(note.position) == "table" and note.position.old_path or nil) root_new_line = (type(note.position) == "table" and note.position.new_line or nil) root_old_line = (type(note.position) == "table" and note.position.old_line or nil) + root_head_sha = (type(note.position) == "table" and note.position.head_sha) + root_base_sha = (type(note.position) == "table" and note.position.base_sha) root_id = discussion.id root_note_id = tostring(note.id) resolvable = note.resolvable resolved = note.resolved root_url = state.INFO.web_url .. "#note_" .. note.id range = (type(note.position) == "table" and note.position.line_range or nil) + system = note.system else -- Otherwise insert it as a child node... local note_node = M.build_note(note) table.insert(discussion_children, note_node) @@ -85,8 +91,11 @@ M.add_discussions_to_table = function(items, unlinked) old_file_name = root_old_file_name, new_line = root_new_line, old_line = root_old_line, + head_sha = root_head_sha, + base_sha = root_base_sha, resolvable = resolvable, resolved = resolved, + system = system, url = root_url, }, body) @@ -310,7 +319,10 @@ M.build_note = function(note, resolve_info) 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), + head_sha = (type(note.position) == "table" and note.position.head_sha), + base_sha = (type(note.position) == "table" and note.position.base_sha), url = state.INFO.web_url .. "#note_" .. note.id, + system = note.system, type = "note", }, text_nodes) diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 7b1dc252..01136958 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -255,7 +255,7 @@ M.get_mode = function() end ---Toggles the current view type (or sets it to `override`) and then updates the view. ----@param override "discussions"|"notes" Defines the view type to select. +---@param override? "discussions"|"notes" Defines the view type to select. M.switch_view_type = function(override) if override then M.current_view_type = override diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index e23665e9..40aca960 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -4,6 +4,7 @@ -- under lua/gitlab/actions/discussions/init.lua local common = require("gitlab.actions.common") local discussion_tree = require("gitlab.actions.discussions.tree") +local git = require("gitlab.git") local job = require("gitlab.job") local NuiTree = require("nui.tree") local List = require("gitlab.utils.list") @@ -85,12 +86,20 @@ end ---Publishes all draft notes and comments. Re-renders all discussion views. M.confirm_publish_all_drafts = function() + if not git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then + return + end local body = { publish_all = true } job.run_job("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) state.DRAFT_NOTES = {} - local discussions = require("gitlab.actions.discussions") - discussions.rebuild_view(false, true) + require("gitlab.actions.discussions").rebuild_view(false, true) + end, function() + require("gitlab.actions.discussions").rebuild_view(false, true) + u.notify( + "Draft(s) may have been published despite the error. Check the discussion tree. Try publishing drafts individually.", + vim.log.levels.WARN + ) end) end @@ -99,6 +108,9 @@ end ---and re-render it. ---@param tree NuiTree M.confirm_publish_draft = function(tree) + if not git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then + return + end 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) @@ -111,12 +123,13 @@ M.confirm_publish_draft = function(tree) ---@type integer local note_id = note_node.is_root and root_node.id or note_node.id local body = { note = note_id } + local unlinked = tree.bufnr == require("gitlab.actions.discussions").unlinked_bufnr job.run_job("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) - - local discussions = require("gitlab.actions.discussions") - local unlinked = tree.bufnr == discussions.unlinked_bufnr M.rebuild_view(unlinked) + end, function() + M.rebuild_view(unlinked) + u.notify("Draft may have been published despite the error. Check the discussion tree.", vim.log.levels.WARN) end) end @@ -142,8 +155,10 @@ M.build_root_draft_note = function(note) id = note.id, root_note_id = note.id, file_name = (type(note.position) == "table" and note.position.new_path or nil), + old_file_name = (type(note.position) == "table" and note.position.old_path or nil), new_line = (type(note.position) == "table" and note.position.new_line or nil), old_line = (type(note.position) == "table" and note.position.old_line or nil), + head_sha = (type(note.position) == "table" and note.position.head_sha or nil), resolvable = false, resolved = false, url = state.INFO.web_url .. "#note_" .. note.id, diff --git a/lua/gitlab/actions/help.lua b/lua/gitlab/actions/help.lua index 991a1623..3fed1556 100644 --- a/lua/gitlab/actions/help.lua +++ b/lua/gitlab/actions/help.lua @@ -6,7 +6,12 @@ local state = require("gitlab.state") local List = require("gitlab.utils.list") local Popup = require("nui.popup") -M.open = function() +---@class HelpPopupOpts +---@field discussion_tree boolean|nil Whether help popup is for the discussion tree + +--- @param opts HelpPopupOpts|nil Table with options for the help popup +M.open = function(opts) + local help_opts = opts or {} local bufnr = vim.api.nvim_get_current_buf() local keymaps = vim.api.nvim_buf_get_keymap(bufnr, "n") local help_content_lines = List.new(keymaps):reduce(function(agg, keymap) @@ -17,26 +22,28 @@ M.open = function() return agg end, {}) - table.insert(help_content_lines, "") - table.insert( - help_content_lines, - string.format( - "%s = draft; %s = unlinked comment; %s = resolved", - state.settings.discussion_tree.draft, - state.settings.discussion_tree.unlinked, - state.settings.discussion_tree.resolved + if help_opts.discussion_tree then + table.insert(help_content_lines, "") + table.insert( + help_content_lines, + string.format( + "%s = draft; %s = unlinked comment; %s = resolved", + state.settings.discussion_tree.draft, + state.settings.discussion_tree.unlinked, + state.settings.discussion_tree.resolved + ) ) - ) + end local longest_line = u.get_longest_string(help_content_lines) - local opts = { "Help", state.settings.popup.help, longest_line + 3, #help_content_lines, 70 } - local help_popup = Popup(popup.create_popup_state(unpack(opts))) + local popup_opts = { "Help", state.settings.popup.help, longest_line + 3, #help_content_lines, 70 } + local help_popup = Popup(popup.create_popup_state(unpack(popup_opts))) help_popup:on(event.BufLeave, function() help_popup:unmount() end) - popup.set_up_autocommands(help_popup, nil, vim.api.nvim_get_current_win(), opts) + popup.set_up_autocommands(help_popup, nil, vim.api.nvim_get_current_win(), popup_opts) help_popup:mount() diff --git a/lua/gitlab/actions/suggestions.lua b/lua/gitlab/actions/suggestions.lua new file mode 100644 index 00000000..0cbba441 --- /dev/null +++ b/lua/gitlab/actions/suggestions.lua @@ -0,0 +1,735 @@ +---This module is responsible for previewing changes suggested in comments. +---The data required to make the API calls are drawn from the discussion nodes. + +local git = require("gitlab.git") +local List = require("gitlab.utils.list") +local u = require("gitlab.utils") +local indicators_common = require("gitlab.indicators.common") + +local M = {} + +vim.fn.sign_define("GitlabSuggestion", { + text = "+", + texthl = "WarningMsg", +}) + +local suggestion_namespace = vim.api.nvim_create_namespace("gitlab_suggestion_note") + +---Refresh the diagnostics from LSP in the suggestions buffer if there are any clients that support +---diagnostics. +---@param suggestion_buf integer Number of the buffer with applied suggestions (can be local or scratch). +local refresh_lsp_diagnostics = function(suggestion_buf) + for _, client in ipairs(vim.lsp.get_clients({ bufnr = suggestion_buf })) do + if client:supports_method("textDocument/diagnostic", suggestion_buf) then + vim.lsp.buf_request(suggestion_buf, "textDocument/diagnostic", { + textDocument = vim.lsp.util.make_text_document_params(suggestion_buf), + }) + end + end +end + +---Reset the contents of the suggestion buffer. +---@param bufnr integer The number of the suggestion buffer. +---@param lines string[] Lines of text to put into the buffer. +---@param imply_local boolean True if buffer is local file and should be written. +local set_buffer_lines = function(bufnr, lines, imply_local) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + -- Recompute and re-apply folds (Otherwise folds are messed up when TextChangedI is triggered). + -- TODO: Find out if it's a (Neo)vim bug. + vim.api.nvim_buf_call(bufnr, function() + vim.cmd("normal! zX") + end) + + if imply_local then + vim.api.nvim_buf_call(bufnr, function() + vim.api.nvim_cmd({ cmd = "write", mods = { silent = true } }, {}) + end) + refresh_lsp_diagnostics(bufnr) + end +end + +---Reset suggestion buffer options and keymaps before closing the preview. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@param suggestion_buf integer Suggestion buffer number. +---@param original_lines string[] The list of lines in the original (commented on) version of the file. +---@param original_suggestion_winbar string The original suggestion buffer/window 'winbar'. +---@param suggestion_winid integer Suggestion window number in the preview tab. +local reset_suggestion_buf = function( + imply_local, + suggestion_buf, + original_lines, + original_suggestion_winbar, + suggestion_winid +) + local keymaps = require("gitlab.state").settings.keymaps + set_buffer_lines(suggestion_buf, original_lines, imply_local) + if imply_local then + pcall(vim.api.nvim_buf_del_keymap, suggestion_buf, "n", keymaps.suggestion_preview.discard_changes) + pcall(vim.api.nvim_buf_del_keymap, suggestion_buf, "n", keymaps.suggestion_preview.apply_changes) + vim.api.nvim_set_option_value("winbar", original_suggestion_winbar, { scope = "local", win = suggestion_winid }) + end +end + +---Set keymaps for the suggestion tab buffers. +---@param note_buf integer Number of the note buffer. +---@param original_buf integer Number of the buffer with the original contents of the file. +---@param suggestion_buf integer Number of the buffer with applied suggestions (can be local or scratch). +---@param original_lines string[] The list of lines in the original (commented on) version of the file. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@param default_suggestion_lines string[] The default suggestion lines with backticks. +---@param original_suggestion_winbar string The original suggestion buffer/window 'winbar'. +---@param suggestion_winid integer Suggestion window number in the preview tab. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +local set_keymaps = function( + note_buf, + original_buf, + suggestion_buf, + original_lines, + imply_local, + default_suggestion_lines, + original_suggestion_winbar, + suggestion_winid, + opts +) + local keymaps = require("gitlab.state").settings.keymaps + + for _, bufnr in ipairs({ note_buf, original_buf, suggestion_buf }) do + -- Reset suggestion buffer to original state and close preview tab + if keymaps.suggestion_preview.discard_changes then + vim.keymap.set("n", keymaps.suggestion_preview.discard_changes, function() + if vim.api.nvim_buf_is_valid(note_buf) then + vim.bo[note_buf].modified = false + end + -- Resetting can cause invalid-buffer errors for temporary (non-local) suggestion buffer + if imply_local then + reset_suggestion_buf( + imply_local, + suggestion_buf, + original_lines, + original_suggestion_winbar, + suggestion_winid + ) + end + vim.cmd.tabclose() + end, { + buffer = bufnr, + desc = "Close preview tab discarding changes", + nowait = keymaps.suggestion_preview.discard_changes_nowait, + }) + end + + -- Post suggestion note to the server. + if keymaps.suggestion_preview.apply_changes then + vim.keymap.set("n", keymaps.suggestion_preview.apply_changes, function() + vim.api.nvim_buf_call(note_buf, function() + vim.api.nvim_cmd({ cmd = "write", mods = { silent = true } }, {}) + end) + + local buf_text = u.get_buffer_text(note_buf) + if opts.comment_type == "reply" then + require("gitlab.actions.comment").confirm_create_comment(buf_text, false, opts.root_node_id) + elseif opts.comment_type == "draft" then + require("gitlab.actions.draft_notes").confirm_edit_draft_note(opts.note_node_id, false)(buf_text) + elseif opts.comment_type == "edit" then + require("gitlab.actions.comment").confirm_edit_comment(opts.root_node_id, opts.note_node_id, false)(buf_text) + elseif opts.comment_type == "new" then + require("gitlab.actions.comment").confirm_create_comment(buf_text, false) + elseif opts.comment_type == "apply" then + if not imply_local then + u.notify("Cannot apply temp-file preview to local file.", vim.log.levels.ERROR) + return + end + if git.has_staged_changes() then + u.notify("Cannot commit suggestion when there are staged changes", vim.log.levels.ERROR) + return + end + if + not git.add({ filename = vim.fn.bufname(suggestion_buf) }) + or not git.commit({ commit_message = "Apply 1 suggestion to 1 file" }) + or not git.push() + then + return + end + require("gitlab.actions.discussions").toggle_discussion_resolved(opts.tree, true) + original_lines = vim.api.nvim_buf_get_lines(suggestion_buf, 0, -1, false) + else + -- This should not really happen. + u.notify(string.format("Cannot perform unsupported action `%s`", opts.comment_type), vim.log.levels.ERROR) + end + + reset_suggestion_buf(imply_local, suggestion_buf, original_lines, original_suggestion_winbar, suggestion_winid) + vim.cmd.tabclose() + end, { + buffer = bufnr, + desc = opts.comment_type == "apply" and "Apply suggestion and resolve thread" + or "Post suggestion comment to Gitlab", + nowait = keymaps.suggestion_preview.apply_changes_nowait, + }) + end + + if opts.comment_type == "apply" and keymaps.suggestion_preview.apply_changes_locally then + vim.keymap.set("n", keymaps.suggestion_preview.apply_changes_locally, function() + vim.api.nvim_buf_call(note_buf, function() + vim.api.nvim_cmd({ cmd = "write", mods = { silent = true } }, {}) + end) + if imply_local then + original_lines = vim.api.nvim_buf_get_lines(suggestion_buf, 0, -1, false) + else + u.notify("Cannot apply temp-file preview to local file.", vim.log.levels.ERROR) + return + end + reset_suggestion_buf(imply_local, suggestion_buf, original_lines, original_suggestion_winbar, suggestion_winid) + vim.cmd.tabclose() + end, { + buffer = bufnr, + desc = "Write changes to local file", + nowait = keymaps.suggestion_preview.apply_changes_locally_nowait, + }) + end + + if keymaps.help then + vim.keymap.set("n", keymaps.help, function() + local help = require("gitlab.actions.help") + help.open() + end, { buffer = bufnr, desc = "Open help", nowait = keymaps.help_nowait }) + end + end + + if keymaps.suggestion_preview.paste_default_suggestion then + vim.keymap.set("n", keymaps.suggestion_preview.paste_default_suggestion, function() + vim.api.nvim_put(default_suggestion_lines, "l", true, false) + end, { + buffer = note_buf, + desc = "Paste default suggestion", + nowait = keymaps.suggestion_preview.paste_default_suggestion_nowait, + }) + end + + if keymaps.suggestion_preview.attach_file and opts.comment_type ~= "apply" then + vim.keymap.set("n", keymaps.suggestion_preview.attach_file, function() + require("gitlab.actions.miscellaneous").attach_file() + end, { + buffer = note_buf, + desc = "Attach file", + nowait = keymaps.suggestion_preview.attach_file_nowait, + }) + end +end + +---Replace a range of items in a list with items from another list. +---@param full_text string[] The full list of lines. +---@param start_idx integer The beginning of the range to be replaced. +---@param end_idx integer The end of the range to be replaced. +---@param new_lines string[] The lines of text that should replace the original range. +---@param note_start_linenr number The line number in the note text where the suggesion begins +---@return string[] new_tbl The new list of lines after replacing. +local replace_line_range = function(full_text, start_idx, end_idx, new_lines, note_start_linenr) + if start_idx < 1 then + u.notify( + string.format("Can't apply suggestion at line %d, invalid start of range.", note_start_linenr), + vim.log.levels.ERROR + ) + return full_text + end + -- Copy the original text + local new_tbl = {} + for _, val in ipairs(full_text) do + table.insert(new_tbl, val) + end + -- Remove old lines + for _ = start_idx, end_idx do + table.remove(new_tbl, start_idx) + end + -- Insert new lines + for i, line in ipairs(new_lines) do + table.insert(new_tbl, start_idx + i - 1, line) + end + return new_tbl +end + +---Refresh the signs in the note buffer. +---@param suggestion Suggestion The data for an individual suggestion. +---@param note_buf integer The number of the note buffer. +local refresh_signs = function(suggestion, note_buf) + vim.fn.sign_unplace("gitlab.suggestion") + if suggestion.is_default then + return + end + vim.fn.sign_place( + suggestion.note_start_linenr, + "gitlab.suggestion", + "GitlabSuggestion", + note_buf, + { lnum = suggestion.note_start_linenr } + ) + vim.fn.sign_place( + suggestion.note_end_linenr, + "gitlab.suggestion", + "GitlabSuggestion", + note_buf, + { lnum = suggestion.note_end_linenr } + ) +end + +---Create the name for a temporary file. +---@param revision string The revision of the file for which the comment was made. +---@param node_id string|integer The id of the note node containing the suggestion. +---@param file_name string The name of the commented file. +---@return string buf_name The full name of the new buffer. +---@return integer bufnr The number of the buffer associated with the new name (-1 if buffer doesn't exist). +local get_temp_file_name = function(revision, node_id, file_name) + -- TODO: Come up with a nicer naming convention. + local buf_name = string.format("gitlab::%s/%s::%s", revision, node_id, file_name) + local bufnr = vim.fn.bufnr(buf_name) + return buf_name, bufnr +end + +---Get the text on which the suggestion was created. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +---@return string[]|nil original_lines The list of original lines. +local get_original_lines = function(opts) + local original_head_text = git.get_file_revision({ + file_name = opts.is_new_sha and opts.new_file_name or opts.old_file_name, + revision = opts.revision, + }) + -- If the original revision doesn't contain the file, the branch was possibly rebased, and the + -- original revision could not been found. + if original_head_text == nil then + u.notify( + string.format( + "File `%s` doesn't contain any text in revision `%s` for which comment was made", + opts.old_file_name, + opts.revision + ), + vim.log.levels.WARN + ) + return + end + return vim.fn.split(original_head_text, "\n", true) +end + +---Create the default suggestion lines for given comment range. +---@param original_lines string[] The list of lines in the original (commented on) version of the file. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +---@return string[] suggestion_lines +local get_default_suggestion = function(original_lines, opts) + local backticks = "```" + local selected_lines = { unpack(original_lines, opts.start_line, opts.end_line) } + for _, line in ipairs(selected_lines) do + local match = string.match(line, "^%s*(`+)%s*$") + if match and #match >= #backticks then + backticks = match .. "`" + end + end + local suggestion_lines = { backticks .. "suggestion:-" .. (opts.end_line - opts.start_line) .. "+0" } + vim.list_extend(suggestion_lines, selected_lines) + table.insert(suggestion_lines, backticks) + return suggestion_lines +end + +---Check if buffer already exists and return the number of the tab it's open in. +---@param bufnr integer The buffer number to check. +---@return number|nil tabnr The tabpage number if buffer is already open, or nil. +local get_tabnr_for_buf = function(bufnr) + for _, tabnr in ipairs(vim.api.nvim_list_tabpages()) do + for _, winnr in ipairs(vim.api.nvim_tabpage_list_wins(tabnr)) do + if vim.api.nvim_win_get_buf(winnr) == bufnr then + return tabnr + end + end + end + return nil +end + +---@class Suggestion +---@field start_line_offset number The offset for the start of the suggestion (e.g., "2" in suggestion:-2+3) +---@field end_line_offset number The offset for the end of the suggestion (e.g., "3" in suggestion:-2+3) +---@field note_start_linenr number The line number in the note text where the suggesion begins +---@field note_end_linenr number The line number in the note text where the suggesion ends +---@field lines string[] The text of the suggesion +---@field full_text string[] The full text of the file with the suggesion applied +---@field is_default boolean If true, the "suggestion" is a placeholder for comments without actual suggestions. + +---Create the suggestion list from the note text. +---@param note_lines string[] The content of the comment. +---@param end_line integer The last line number of the comment range. +---@param original_lines string[] Array of original lines. +---@return Suggestion[] suggestions List of suggestion data. +local get_suggestions = function(note_lines, end_line, original_lines) + local suggestions = {} + local in_suggestion = false + local suggestion = {} + local quote + + for i, line in ipairs(note_lines) do + local start_quote = string.match(line, "^%s*(`+)suggestion:%-%d+%+%d+") + local end_quote = string.match(line, "^%s*(`+)%s*$") + if start_quote ~= nil and not in_suggestion then + quote = start_quote + in_suggestion = true + suggestion.start_line_offset, suggestion.end_line_offset = string.match(line, "^%s*`+suggestion:%-(%d+)%+(%d+)") + suggestion.note_start_linenr = i + suggestion.lines = {} + elseif in_suggestion and end_quote and end_quote == quote then + suggestion.note_end_linenr = i + + -- Add the full text with the changes applied to the original text. + local start_line = end_line - suggestion.start_line_offset + local end_line_number = end_line + suggestion.end_line_offset + suggestion.full_text = + replace_line_range(original_lines, start_line, end_line_number, suggestion.lines, suggestion.note_start_linenr) + + table.insert(suggestions, suggestion) + in_suggestion = false + suggestion = {} + elseif in_suggestion then + table.insert(suggestion.lines, line) + end + end + + if #suggestions == 0 then + suggestions = { + { + start_line_offset = 0, + end_line_offset = 0, + note_start_linenr = 1, + note_end_linenr = 1, + lines = {}, + full_text = original_lines, + is_default = true, + }, + } + end + return suggestions +end + +---Return true if the file has uncommitted or unsaved changes. +---@param file_name string Name of file to check. +---@return boolean +local is_modified = function(file_name) + local has_changes = git.has_changes(file_name) + local bufnr = vim.fn.bufnr(file_name, true) + if vim.bo[bufnr].modified or has_changes then + return true + end + return false +end + +---Decide if local file should be used to show suggestion preview. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +local determine_imply_local = function(opts) + local head_differs_from_original = git.file_differs_in_revisions({ + revision_1 = opts.revision, + revision_2 = "HEAD", + old_file_name = opts.old_file_name, + file_name = opts.new_file_name, + }) + -- TODO: Find out if this condition is not too restrictive (comment on unchanged lines could be + -- shown in local file just fine). Ideally, change logic of showing comments on unchanged lines + -- from OLD to NEW version (to enable more local-file diffing). + if not opts.is_new_sha then + u.notify("Comment on old text. Using target-branch version", vim.log.levels.WARN) + elseif head_differs_from_original then + u.notify("Line changed. Using version for which comment was made", vim.log.levels.WARN) + elseif is_modified(opts.new_file_name) then + u.notify("File has unsaved or uncommited changes", vim.log.levels.WARN) + else + return true + end + return false +end + +---Create diagnostics data from suggesions. +---@param suggestions Suggestion[] The list of suggestions data for the current note. +---@return vim.Diagnostic[] diagnostics_data List of diagnostic data for vim.diagnostic.set. +local create_diagnostics = function(suggestions) + local diagnostics_data = {} + for _, suggestion in ipairs(suggestions) do + if not suggestion.is_default then + local diagnostic = { + message = table.concat(suggestion.lines, "\n") .. "\n", + col = 0, + severity = vim.diagnostic.severity.INFO, + source = "gitlab", + code = "gitlab.nvim", + lnum = suggestion.note_start_linenr - 1, + } + table.insert(diagnostics_data, diagnostic) + end + end + return diagnostics_data +end + +---Show diagnostics for suggestions (enables using built-in navigation with `]d` and `[d`). +---@param suggestions Suggestion[] The list of suggestions for which diagnostics should be created. +---@param note_buf integer The number of the note buffer +local refresh_diagnostics = function(suggestions, note_buf) + local diagnostics_data = create_diagnostics(suggestions) + vim.diagnostic.reset(suggestion_namespace, note_buf) + vim.diagnostic.set(suggestion_namespace, note_buf, diagnostics_data, indicators_common.create_display_opts()) +end + +---Get the highlighted text for the edit mode of the suggestion buffer. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@return string +local get_edit_mode = function(imply_local) + if imply_local then + return "%#GitlabLiveMode#Local file" + else + return "%#GitlabDraftMode#Temp file" + end +end + +---Get the highlighted text for the draft mode. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +---@return string +local get_draft_mode = function(opts) + if opts.comment_type == "draft" or opts.comment_type == "edit" then + return "" + end + if require("gitlab.state").settings.discussion_tree.draft_mode then + return "%#GitlabDraftMode#Draft" + else + return "%#GitlabLiveMode#Live" + end +end + +---Update the winbar on top of the suggestion preview windows. +---@param note_winid integer Note window number. +---@param suggestion_winid integer Suggestion window number in the preview tab. +---@param original_winid integer Original text window number in the preview tab. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +local update_winbar = function(note_winid, suggestion_winid, original_winid, imply_local, opts) + if original_winid ~= -1 then + local content = string.format(" %s: %s ", "%#Normal#original", "%#GitlabUserName#" .. opts.revision) + vim.api.nvim_set_option_value("winbar", content, { scope = "local", win = original_winid }) + end + + if suggestion_winid ~= -1 then + local content = string.format(" %s: %s ", "%#Normal#mode", get_edit_mode(imply_local)) + vim.api.nvim_set_option_value("winbar", content, { scope = "local", win = suggestion_winid }) + end + + if note_winid ~= -1 then + local content = string.format( + " %s: %s %s ", + "%#Normal#" .. opts.comment_type, + "%#GitlabUserName#" .. opts.note_header, + get_draft_mode(opts) + ) + vim.api.nvim_set_option_value("winbar", content, { scope = "local", win = note_winid }) + end +end + +---Create autocommands for the note buffer. +---@param note_buf integer Note buffer number. +---@param note_winid integer Note window number. +---@param suggestion_buf integer Suggestion buffer number. +---@param suggestion_winid integer Suggestion window number in the preview tab. +---@param original_winid integer Original text window number in the preview tab. +---@param suggestions Suggestion[] List of suggestion data. +---@param original_lines string[] Array of original lines. +---@param imply_local boolean True if suggestion buffer is local file and should be written. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +local create_autocommands = function( + note_buf, + note_winid, + suggestion_buf, + suggestion_winid, + original_winid, + suggestions, + original_lines, + imply_local, + opts +) + local last_line = suggestions[1].note_start_linenr + + ---Update the suggestion buffer if the selected suggestion changes in the Comment buffer. + local update_suggestion_buffer = function() + local current_line = vim.fn.line(".") + if current_line == last_line then + return + end + local suggestion = List.new(suggestions):find(function(sug) + return current_line <= sug.note_end_linenr + end) + if not suggestion or u.get_buffer_text(suggestion_buf) == table.concat(suggestion.full_text, "\n") then + return + end + set_buffer_lines(suggestion_buf, suggestion.full_text, imply_local) + last_line = current_line + refresh_signs(suggestion, note_buf) + end + + -- Create autocommand to update the Suggestion buffer when the cursor moves in the Comment buffer. + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { + buffer = note_buf, + callback = function() + update_suggestion_buffer() + end, + }) + + -- Create autocommand to update suggestions list based on the note buffer content. + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + buffer = note_buf, + callback = function() + local updated_note_lines = vim.api.nvim_buf_get_lines(note_buf, 0, -1, false) + suggestions = get_suggestions(updated_note_lines, opts.end_line, original_lines) + last_line = 0 + update_suggestion_buffer() + refresh_diagnostics(suggestions, note_buf) + end, + }) + + -- Update the note buffer header when draft mode is toggled. + local group = vim.api.nvim_create_augroup("GitlabDraftModeToggled" .. note_buf, { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "GitlabDraftModeToggled", + callback = function() + update_winbar(note_winid, suggestion_winid, original_winid, imply_local, opts) + end, + }) + -- Auto-delete the group when the buffer is unloaded. + vim.api.nvim_create_autocmd("BufUnload", { + buffer = note_buf, + group = group, + callback = function() + vim.api.nvim_del_augroup_by_id(group) + end, + }) +end + +---@class ShowPreviewOpts The options passed to the M.show_preview function. +---@field old_file_name string +---@field new_file_name string +---@field start_line integer +---@field end_line integer +---@field is_new_sha boolean +---@field revision string +---@field note_header string +---@field comment_type "apply"|"reply"|"draft"|"edit"|"new" The type of comment ("apply", "reply", "draft" and "edit" come from the discussion tree, "new" from the reviewer) +---@field note_lines string[]|nil +---@field root_node_id string +---@field note_node_id integer +---@field tree NuiTree + +---Get suggestions from the current note and preview them in a new tab. +---@param opts ShowPreviewOpts The options passed to the M.show_preview function. +M.show_preview = function(opts) + if not git.revision_exists(opts.revision) then + u.notify( + string.format("Revision `%s` for which the comment was made does not exist", opts.revision), + vim.log.levels.ERROR + ) + return + end + + local commented_file_name = opts.is_new_sha and opts.new_file_name or opts.old_file_name + local original_buf_name, original_bufnr = + get_temp_file_name("ORIGINAL", opts.note_node_id or "NEW_COMMENT", commented_file_name) + + -- If preview is already open for given note, go to the tab with a warning. + local tabnr = get_tabnr_for_buf(original_bufnr) + if tabnr ~= nil then + vim.api.nvim_set_current_tabpage(tabnr) + u.notify("Previously created preview can be outdated", vim.log.levels.WARN) + return + end + + local original_lines = get_original_lines(opts) + if original_lines == nil then + return + end + + local note_lines = opts.note_lines or get_default_suggestion(original_lines, opts) + local suggestions = get_suggestions(note_lines, opts.end_line, original_lines) + + -- Create new tab with a temp buffer showing the original version on which the comment was + -- made. + vim.api.nvim_cmd({ cmd = "tabnew", args = { original_buf_name } }, {}) + local original_buf = vim.api.nvim_get_current_buf() + local original_winid = vim.api.nvim_get_current_win() + vim.api.nvim_buf_set_lines(original_buf, 0, -1, false, original_lines) + vim.bo.bufhidden = "wipe" + vim.bo.buflisted = false + vim.bo.buftype = "nofile" + vim.bo.modifiable = false + vim.cmd.filetype("detect") + local buf_filetype = vim.api.nvim_get_option_value("filetype", { buf = 0 }) + + local imply_local = determine_imply_local(opts) + + -- Create the suggestion buffer and show a diff with the original version + local split_cmd = vim.o.columns > 240 and "vsplit" or "split" + if imply_local then + vim.api.nvim_cmd({ cmd = split_cmd, args = { opts.new_file_name } }, {}) + else + local sug_buf_name = get_temp_file_name("SUGGESTION", opts.note_node_id or "NEW_COMMENT", commented_file_name) + vim.api.nvim_cmd({ cmd = split_cmd, args = { sug_buf_name } }, {}) + vim.bo.bufhidden = "wipe" + vim.bo.buflisted = false + vim.bo.buftype = "nofile" + vim.bo.filetype = buf_filetype + end + local suggestion_buf = vim.api.nvim_get_current_buf() + local suggestion_winid = vim.api.nvim_get_current_win() + set_buffer_lines(suggestion_buf, suggestions[1].full_text, imply_local) + vim.cmd("1,2windo diffthis") + + -- Backup the suggestion buffer winbar to reset it when suggestion preview is closed. Despite the + -- option being "window-local", it's carried over to the buffer even after closing the preview. + -- See https://github.com/neovim/neovim/issues/11525 + local suggestion_winbar = vim.api.nvim_get_option_value("winbar", { scope = "local", win = suggestion_winid }) + + -- Create the note window + local note_buf = vim.api.nvim_create_buf(false, false) + local note_winid = vim.fn.win_getid(3) + local note_bufname = vim.fn.tempname() + vim.api.nvim_buf_set_name(note_buf, note_bufname) + vim.api.nvim_cmd({ cmd = "vnew", mods = { split = "botright" }, args = { note_bufname } }, {}) + vim.api.nvim_buf_set_lines(note_buf, 0, -1, false, note_lines) + vim.bo.bufhidden = "wipe" + vim.bo.buflisted = false + vim.bo.filetype = "markdown" + vim.bo.modified = false + + -- Set up keymaps and autocommands + local default_suggestion_lines = get_default_suggestion(original_lines, opts) + set_keymaps( + note_buf, + original_buf, + suggestion_buf, + original_lines, + imply_local, + default_suggestion_lines, + suggestion_winbar, + suggestion_winid, + opts + ) + create_autocommands( + note_buf, + note_winid, + suggestion_buf, + suggestion_winid, + original_winid, + suggestions, + original_lines, + imply_local, + opts + ) + + -- Focus the note window on the first suggestion + vim.api.nvim_win_set_cursor(note_winid, { suggestions[1].note_start_linenr, 0 }) + refresh_signs(suggestions[1], note_buf) + refresh_diagnostics(suggestions, note_buf) + update_winbar(note_winid, suggestion_winid, original_winid, imply_local, opts) +end + +return M diff --git a/lua/gitlab/colors.lua b/lua/gitlab/colors.lua index 54d557a5..2d8bfc8d 100644 --- a/lua/gitlab/colors.lua +++ b/lua/gitlab/colors.lua @@ -18,7 +18,7 @@ local function get_colors_for_group(group) local normal_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(group)), "bg") return { fg = normal_fg, bg = normal_bg } end -vim.api.nvim_create_autocmd("VimEnter", { +vim.api.nvim_create_autocmd({ "VimEnter", "ColorScheme" }, { callback = function() vim.api.nvim_set_hl(0, "GitlabUsername", get_colors_for_group(discussion.username)) vim.api.nvim_set_hl(0, "GitlabMention", get_colors_for_group(discussion.mention)) diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index a633c86d..c5c8676e 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -6,11 +6,12 @@ local M = {} ---@param command table ---@return string|nil, string|nil local run_system = function(command) - -- Load here to prevent loop - local u = require("gitlab.utils") - local result = vim.fn.trim(vim.fn.system(command)) + -- Preserve trailing newlines when getting contents of file revisions + local result = vim.fn.join(vim.fn.systemlist(command), "\n") if vim.v.shell_error ~= 0 then - u.notify(result, vim.log.levels.ERROR) + if result ~= "" then + require("gitlab.utils").notify(result, vim.log.levels.ERROR) + end return nil, result end return result, nil @@ -52,10 +53,28 @@ M.switch_branch = function(branch) return run_system({ "git", "checkout", "-q", branch }) end ----Fetches the name of the remote tracking branch for the current branch ----@return string|nil, string|nil +---Returns the name of the remote-tracking branch for the current branch or nil if it can't be found +---@return string|nil M.get_remote_branch = function() - return run_system({ "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}" }) + local remote_branch, err = run_system({ "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}" }) + if err or remote_branch == "" then + require("gitlab.utils").notify("Could not get remote branch: " .. err, vim.log.levels.ERROR) + return nil + end + return remote_branch +end + +---Fetch the remote branch +---@param remote_branch string The name of the repo and branch to fetch (e.g., "origin/some_branch") +---@return boolean fetch_successfull False if an error occurred while fetching, true otherwise. +M.fetch_remote_branch = function(remote_branch) + local remote, branch = string.match(remote_branch, "([^/]+)/(.*)") + local _, fetch_err = run_system({ "git", "fetch", remote, branch }) + if fetch_err ~= nil then + require("gitlab.utils").notify("Error fetching remote-tracking branch: " .. fetch_err, vim.log.levels.ERROR) + return false + end + return true end ---Determines whether the tracking branch is ahead of or behind the current branch, and warns the user if so @@ -64,6 +83,10 @@ end ---@param log_level number ---@return boolean M.get_ahead_behind = function(current_branch, remote_branch, log_level) + if not M.fetch_remote_branch(remote_branch) then + return false + end + local u = require("gitlab.utils") local result, err = run_system({ "git", "rev-list", "--left-right", "--count", current_branch .. "..." .. remote_branch }) @@ -104,17 +127,22 @@ M.get_ahead_behind = function(current_branch, remote_branch, log_level) return true -- Checks passed, branch is up-to-date end ----Return the name of the current branch ----@return string|nil, string|nil +---Return the name of the current branch or nil if it can't be retrieved +---@return string|nil M.get_current_branch = function() - return run_system({ "git", "branch", "--show-current" }) + local current_branch, err = run_system({ "git", "branch", "--show-current" }) + if err or current_branch == "" then + require("gitlab.utils").notify("Could not get current branch: " .. err, vim.log.levels.ERROR) + return nil + end + return current_branch end ---Return the list of possible merge targets. ---@return table|nil M.get_all_merge_targets = function() - local current_branch, err = M.get_current_branch() - if not current_branch or err ~= nil then + local current_branch = M.get_current_branch() + if current_branch == nil then return end return List.new(M.get_all_remote_branches()):filter(function(branch) @@ -158,19 +186,13 @@ end ---@param log_level integer ---@return boolean M.check_current_branch_up_to_date_on_remote = function(log_level) - local u = require("gitlab.utils") - - -- Get current branch - local current_branch, err_current_branch = M.get_current_branch() - if err_current_branch or not current_branch then - u.notify("Could not get current branch: " .. err_current_branch, vim.log.levels.ERROR) + local current_branch = M.get_current_branch() + if current_branch == nil then return false end - -- Get remote tracking branch - local remote_branch, err_remote_branch = M.get_remote_branch() - if err_remote_branch or not remote_branch then - u.notify("Could not get remote branch: " .. err_remote_branch, vim.log.levels.ERROR) + local remote_branch = M.get_remote_branch() + if remote_branch == nil then return false end @@ -195,4 +217,100 @@ M.check_mr_in_good_condition = function() end end +---@class GetFileRevisionOpts +---@field revision string The SHA of the revision to get +---@field file_name string The name of the file to get + +---Returns the contents of the file in a given revision +---@param args GetFileRevisionOpts extra arguments for `git show` +---@return string|nil, string|nil +M.get_file_revision = function(args) + if args.revision == nil or args.file_name == nil then + return + end + local object = string.format("%s:%s", args.revision, args.file_name) + return run_system({ "git", "show", object }) +end + +---Returns true if the given revision exists, false otherwise +---@param revision string The revision to check +---@return boolean +M.revision_exists = function(revision) + if revision == nil then + require("gitlab.utils").notify("Invalid nil revision", vim.log.levels.ERROR) + return false + end + local object = string.format("%s", revision) + local result = run_system({ "git", "rev-parse", "--verify", "--quiet", "--end-of-options", object }) + return result ~= nil +end + +---@class FileDiffersInRevisionsOpts +---@field revision_1 string +---@field revision_2 string +---@field old_file_name string +---@field file_name string + +---Returns true if the file differs in two revisions (handles renames) +---@param opts FileDiffersInRevisionsOpts +---@return boolean +M.file_differs_in_revisions = function(opts) + local result = + run_system({ "git", "diff", "-M", opts.revision_1, opts.revision_2, "--", opts.old_file_name, opts.file_name }) + return result ~= "" +end + +---@class AddOpts +---@field filename string The file to stage + +---Returns true if staging succeeds, false otherwise +---@param opts AddOpts +---@return boolean +M.add = function(opts) + local _, add_err = run_system({ "git", "add", opts.filename }) + if add_err ~= nil then + require("gitlab.utils").notify("Adding changes failed: " .. add_err, vim.log.levels.ERROR) + return false + end + return true +end + +---@class CommitOpts +---@field commit_message string The commit message to include in the commit + +---Returns true if the commit succeeds, false otherwise +---@param opts CommitOpts +---@return boolean +M.commit = function(opts) + local _, commit_err = run_system({ "git", "commit", "-m", opts.commit_message, "-q" }) + if commit_err ~= nil then + require("gitlab.utils").notify("Committing changes failed: " .. commit_err, vim.log.levels.ERROR) + return false + end + return true +end + +---Returns true if there are staged changes +---@return boolean +M.has_staged_changes = function() + local result = run_system({ "git", "diff", "--staged" }) + return result ~= "" +end + +---Returns true if the push succeeds, false otherwise +---@return boolean +M.push = function() + local remote_branch = M.get_remote_branch() + if remote_branch == nil then + return false + end + local remote, branch = string.match(remote_branch, "([^/]+)/(.*)") + local _, push_err = run_system({ "git", "push", remote, branch }) + if push_err ~= nil then + require("gitlab.utils").notify("Pushing remote-tracking branch failed: " .. push_err, vim.log.levels.ERROR) + return false + end + return true +end + return M diff --git a/lua/gitlab/indicators/common.lua b/lua/gitlab/indicators/common.lua index 04f68acc..1a42111e 100644 --- a/lua/gitlab/indicators/common.lua +++ b/lua/gitlab/indicators/common.lua @@ -10,6 +10,16 @@ local M = {} ---@field resolved boolean|nil ---@field created_at string|nil +-- Display options for the diagnostic +M.create_display_opts = function() + return { + virtual_text = state.settings.discussion_signs.virtual_text, + severity_sort = true, + underline = false, + signs = state.settings.discussion_signs.use_diagnostic_signs, + } +end + ---Return true if discussion has a placeable diagnostic, false otherwise. ---@param note NoteWithValues ---@return boolean diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index e32e086f..602d57d2 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -14,16 +14,6 @@ M.clear_diagnostics = function() vim.diagnostic.reset(diagnostics_namespace) end --- Display options for the diagnostic -local create_display_opts = function() - return { - virtual_text = state.settings.discussion_signs.virtual_text, - severity_sort = true, - underline = false, - signs = state.settings.discussion_signs.use_diagnostic_signs, - } -end - ---Takes some range information and data about a discussion ---and creates a diagnostic to be placed in the reviewer ---@param range_info table @@ -114,6 +104,9 @@ end ---Filter and place the diagnostics for the given buffer. ---@param bufnr number The number of the buffer for placing diagnostics. M.place_diagnostics = function(bufnr) + if bufnr and vim.api.nvim_buf_get_name(bufnr) == "diffview://null" then + return + end if not state.settings.discussion_signs.enabled then return end @@ -137,9 +130,19 @@ M.place_diagnostics = function(bufnr) local new_diagnostics, old_diagnostics = List.new(file_discussions):partition(indicators_common.is_new_sha) if bufnr == view.cur_layout.a.file.bufnr then - set_diagnostics(diagnostics_namespace, bufnr, M.parse_diagnostics(old_diagnostics), create_display_opts()) + set_diagnostics( + diagnostics_namespace, + bufnr, + M.parse_diagnostics(old_diagnostics), + indicators_common.create_display_opts() + ) elseif bufnr == view.cur_layout.b.file.bufnr then - set_diagnostics(diagnostics_namespace, bufnr, M.parse_diagnostics(new_diagnostics), create_display_opts()) + set_diagnostics( + diagnostics_namespace, + bufnr, + M.parse_diagnostics(new_diagnostics), + indicators_common.create_display_opts() + ) end end) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index f17c6320..a3b0a0e4 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -67,6 +67,7 @@ return { create_comment = async.sequence({ info, revisions }, comment.create_comment), create_multiline_comment = async.sequence({ info, revisions }, comment.create_multiline_comment), create_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion), + create_comment_with_suggestion = async.sequence({ info, revisions }, comment.create_comment_with_suggestion), move_to_discussion_tree_from_diagnostic = async.sequence({}, discussions.move_to_discussion_tree), create_note = async.sequence({ info }, comment.create_note), create_mr = async.sequence({}, create_mr.start), diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index 128591be..bccd8062 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -4,7 +4,7 @@ local Job = require("plenary.job") local u = require("gitlab.utils") local M = {} -M.run_job = function(endpoint, method, body, callback) +M.run_job = function(endpoint, method, body, callback, on_error_callback) local state = require("gitlab.state") local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s", state.settings.port) .. endpoint } @@ -16,8 +16,10 @@ M.run_job = function(endpoint, method, body, callback) -- This handler will handle all responses from the Go server. Anything with a successful -- status will call the callback (if it is supplied for the job). Otherwise, it will print out the - -- success message or error message and details from the Go server. + -- success message or error message and details from the Go server and run the on_error_callback + -- (if supplied for the job). local stderr = {} + Job:new({ command = "curl", args = args, @@ -53,6 +55,9 @@ M.run_job = function(endpoint, method, body, callback) -- Handle error case local message = string.format("%s: %s", data.message, data.details) u.notify(message, vim.log.levels.ERROR) + if on_error_callback then + on_error_callback(data) + end end end, 0) end, diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 80eb10af..170bf7d4 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -110,6 +110,9 @@ end ---@param line_number number Line number from the discussion node. ---@param new_buffer boolean If true, jump to the NEW SHA. M.jump = function(file_name, old_file_name, line_number, new_buffer) + -- Draft comments don't have `old_file_name` set + old_file_name = old_file_name or file_name + if M.tabnr == nil then u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) return @@ -123,7 +126,8 @@ M.jump = function(file_name, old_file_name, line_number, new_buffer) local files = view.panel:ordered_file_list() local file = List.new(files):find(function(f) - return new_buffer and f.path == file_name or f.oldpath == old_file_name + local oldpath = f.oldpath ~= nil and f.oldpath or f.path + return new_buffer and f.path == file_name or oldpath == old_file_name end) if file == nil then u.notify( @@ -195,10 +199,8 @@ M.get_reviewer_data = function(current_win) local opposite_bufnr = new_sha_focused and layout.a.file.bufnr or layout.b.file.bufnr return { - -- TODO: swap 'a' and 'b' to fix lua/gitlab/actions/comment.lua:158, and hopefully also - -- lua/gitlab/indicators/diagnostics.lua:129. - file_name = layout.a.file.path, - old_file_name = M.is_file_renamed() and layout.b.file.path or "", + old_file_name = M.is_file_renamed() and layout.a.file.path or "", + file_name = layout.b.file.path, old_line_from_buf = old_line, new_line_from_buf = new_line, modification_type = modification_type, @@ -241,7 +243,7 @@ end ---@return string|nil M.get_current_file_oldpath = function() local file_data = M.get_current_file_data() - return file_data and file_data.oldpath + return file_data and file_data.oldpath or file_data.path end ---Tell whether current file is renamed or not @@ -428,6 +430,39 @@ M.set_keymaps = function(bufnr) }) end + -- Set mappings for creating suggestions with a preview in a new tab + if keymaps.reviewer.create_suggestion_with_preview ~= false then + -- Set keymap for repeated operator keybinding + vim.keymap.set("o", keymaps.reviewer.create_suggestion_with_preview, function() + -- The "V" in "V%d$" forces linewise motion, see `:h o_V` + vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { string.format("V%d$", vim.v.count1) } }, {}) + end, { + buffer = bufnr, + desc = "Create suggestion with preview for [count] lines", + nowait = keymaps.reviewer.create_suggestion_with_preview_nowait, + }) + + -- Set operator keybinding + vim.keymap.set("n", keymaps.reviewer.create_suggestion_with_preview, function() + M.operator_count = vim.v.count + M.operator = keymaps.reviewer.create_suggestion_with_preview + execute_operatorfunc("create_comment_with_suggestion") + end, { + buffer = bufnr, + desc = "Create suggestion with preview for range of motion", + nowait = keymaps.reviewer.create_suggestion_with_preview_nowait, + }) + + -- Set visual mode keybinding + vim.keymap.set("v", keymaps.reviewer.create_suggestion_with_preview, function() + require("gitlab").create_comment_with_suggestion() + end, { + buffer = bufnr, + desc = "Create suggestion with preview for selected text", + nowait = keymaps.reviewer.create_suggestion_with_preview_nowait, + }) + end + -- Set mapping for moving to discussion tree if keymaps.reviewer.move_to_discussion_tree ~= false then vim.keymap.set("n", keymaps.reviewer.move_to_discussion_tree, function() diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 52627184..e3040f5a 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -62,6 +62,7 @@ M.settings = { }, }, connection_settings = { + proxy = "", insecure = false, remote = "origin", }, @@ -115,6 +116,7 @@ M.settings = { switch_view = "c", toggle_tree_type = "i", publish_draft = "P", + toggle_date_format = "dt", toggle_draft_mode = "D", toggle_sort_method = "st", toggle_node = "t", @@ -123,11 +125,22 @@ M.settings = { toggle_unresolved_discussions = "U", refresh_data = "", print_node = "p", + edit_suggestion = "se", + reply_with_suggestion = "sr", + apply_suggestion = "sa", + }, + suggestion_preview = { + apply_changes = "ZZ", + discard_changes = "ZQ", + attach_file = "ZA", + apply_changes_locally = "Zz", + paste_default_suggestion = "glS", }, reviewer = { disable_all = false, create_comment = "c", create_suggestion = "s", + create_suggestion_with_preview = "S", move_to_discussion_tree = "a", }, }, @@ -169,6 +182,7 @@ M.settings = { draft = "✎", tree_type = "simple", draft_mode = false, + relative_date = true, }, emojis = { formatter = nil,