Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
e7e6f1f
Fix: Make publishing drafts more robust (#483)
jakubbortlik Mar 1, 2025
0f74fc7
feat: add first draft of suggestion preview
jakubbortlik May 30, 2025
e146397
fix: don't attempt placing diagnostics on diffview NULL buffer
jakubbortlik May 30, 2025
a1ccc33
docs: mark parameter as optional
jakubbortlik May 30, 2025
94f7508
fix: go to note in existing tab
jakubbortlik May 30, 2025
509e115
refactor: don't use plain tabnew as it creates empty buffer
jakubbortlik May 31, 2025
911a360
refactor: make functions local
jakubbortlik May 31, 2025
da4640c
docs: add some docstrings
jakubbortlik May 31, 2025
ad6f1f8
fix: add base_sha to draft comments
jakubbortlik Jun 2, 2025
4b99885
fix: use old path when comment is on OLD_SHA
jakubbortlik Jun 2, 2025
4353c31
docs: add TODO
jakubbortlik Jun 2, 2025
f3579eb
docs: update comment
jakubbortlik Jun 2, 2025
4a24a3f
fix: improve checking whether local file should be used for suggestions
jakubbortlik Jun 2, 2025
f5b0851
refactor: simplify imply_local usage
jakubbortlik Jun 3, 2025
78f60d9
docs: update docs
jakubbortlik Jun 3, 2025
939ca85
feat: enable updating suggestion comments from the preview
jakubbortlik Jun 3, 2025
9a4d164
refactor: move more keymap definitions to set_keymaps function
jakubbortlik Jun 3, 2025
b1f0f87
style: format file
jakubbortlik Jun 3, 2025
0fa4333
refactor: create autocommands in a separate function
jakubbortlik Jun 4, 2025
d604537
fix: make note buffer nomodified when discarding changes
jakubbortlik Jun 4, 2025
9daa3e4
fix: validate buffer number before accessing it
jakubbortlik Jun 5, 2025
07f55c8
fix: split horizontally on narrow screen
jakubbortlik Jun 5, 2025
8a17a93
fix: move virtual lines left (and up)
jakubbortlik Jun 5, 2025
5b84434
refactor: pass only tree to show_preview()
jakubbortlik Jun 5, 2025
687383c
fix: check if suggestion preview already exists for given note
jakubbortlik Jun 6, 2025
f94307d
docs: update function annotations
jakubbortlik Jun 6, 2025
0e15c4e
refactor: add full text to suggestions
jakubbortlik Jun 6, 2025
a219aeb
fix: make imply_local local
jakubbortlik Jun 6, 2025
872cc1f
feat: edit suggestions for comments without suggestions
jakubbortlik Jun 7, 2025
6a1d05a
refactor: determine imply_local in separate function
jakubbortlik Jun 7, 2025
5321a4b
fix: prevent error when there are multiple endquotes without a corres…
jakubbortlik Jun 7, 2025
d9b9960
refactor: get original lines in seprate function
jakubbortlik Jun 7, 2025
623586f
fix: show error when suggestion start is before first line of file
jakubbortlik Jun 7, 2025
cde6ab9
fix: convert string to number when editing root node
jakubbortlik Jun 7, 2025
f489533
refactor rename preview suggestion to edit suggestion
jakubbortlik Jun 7, 2025
1bdb2c3
fix: unify keymap setting pattern with popups
jakubbortlik Jun 7, 2025
dd7efa3
docs: remove outdated comment
jakubbortlik Jun 7, 2025
7531153
feat: add keymap for pasting default suggestion
jakubbortlik Jun 7, 2025
9f1bb57
fix: update suggestions on CursorMoved and CursorMovedI
jakubbortlik Jun 7, 2025
f65c663
docs: update comment about winbar
jakubbortlik Jun 7, 2025
86cc493
docs: add TODOs
jakubbortlik Jun 8, 2025
9c6c21b
feat: enable replying to comments in the suggestion preview
jakubbortlik Jun 9, 2025
6997593
feat: show draft mode in note header
jakubbortlik Jun 9, 2025
fd46a3f
fix: enable updating draft replies
jakubbortlik Jun 9, 2025
b69e436
feat: add possibility to create suggestions with preview from the rev…
jakubbortlik Jun 10, 2025
93c199c
docs: fix info about using feature branch
jakubbortlik Jun 11, 2025
9015c84
docs: use simpler info messages
jakubbortlik Jun 13, 2025
73d0a86
fix: check is_reply first
jakubbortlik Jun 13, 2025
d73a4c4
refactor: simplify variable names
jakubbortlik Jun 13, 2025
28cf952
refactor: use ShowPreviewOpts
jakubbortlik Jun 13, 2025
2ae1eab
feat: add mapping for previewing suggestion with head_sha revision
jakubbortlik Jun 18, 2025
f762561
fix: add head_sha to root_node of draft notes
jakubbortlik Jun 20, 2025
e4b653a
feat: replace extmarks by winbar
jakubbortlik Jun 20, 2025
58b6288
feat: add winbar to suggestion window
jakubbortlik Jun 20, 2025
a80134c
feat: add winbar to orignial buffer
jakubbortlik Jun 20, 2025
957c3a7
style: apply stylua
jakubbortlik Jun 20, 2025
54c55d4
fix: refresh LSP diagnostics in suggestion buffer hen settings buffer…
jakubbortlik Jul 11, 2025
dfa40f3
docs: make error message more informative
jakubbortlik Jul 11, 2025
6a2d295
docs: add note why changing modified option
jakubbortlik Jul 11, 2025
17bd0f0
fix: reset suggestion buffer before closing
jakubbortlik Jul 11, 2025
bae8939
style: apply stylua
jakubbortlik Jul 11, 2025
b9e77d6
fix: automatically choose head_sha if file has changed
jakubbortlik Jul 12, 2025
37cd4df
fix: remove unnecessary check
jakubbortlik Jul 12, 2025
0898769
fix: don't reset temporary suggestion buffer before closing preview
jakubbortlik Jul 12, 2025
61a5c89
fix: recompute folds in suggestion buffer on TextChangedI
jakubbortlik Jul 12, 2025
fd00017
docs: improve messages to user
jakubbortlik Jul 14, 2025
f1b56ce
refactor: rename var
jakubbortlik Jul 14, 2025
b5e29c7
feat: add ability to apply suggestion to local file
jakubbortlik Jul 15, 2025
443bd42
docs: use better mapping description
jakubbortlik Jul 17, 2025
f1588f7
docs: add help keymap
jakubbortlik Jul 17, 2025
edc4e85
fix: use mappings in all preview windows
jakubbortlik Jul 17, 2025
927cd03
feat: add attach_file keybinding
jakubbortlik Jul 17, 2025
bb08580
fix: don't create directories for temp files
jakubbortlik Jul 17, 2025
3ac71fe
docs: fix keybinding
jakubbortlik Aug 6, 2025
cc2ea6e
docs: add keybinding description
jakubbortlik Aug 6, 2025
8c5a33d
fix: don't update suggestion buffer if the text doesn't change
jakubbortlik Sep 23, 2025
3fffa43
refactor: simplify checking if suggestion has changed
jakubbortlik Sep 23, 2025
11d1c58
feat: apply suggestion with new commit and resolve thread
jakubbortlik Oct 6, 2025
6f24159
fix: add check that there are no staged changes
jakubbortlik Oct 6, 2025
d50ac71
docs: improve suggestion keymaps descriptions
jakubbortlik Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions doc/gitlab.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -223,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 = "<C-R>", -- Refresh the data in the view by hitting Gitlab's APIs again
print_node = "<leader>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
},
},
Expand Down Expand Up @@ -545,9 +556,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.
Expand Down
42 changes: 38 additions & 4 deletions lua/gitlab/actions/comment.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
60 changes: 55 additions & 5 deletions lua/gitlab/actions/common.lua
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,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
Expand Down Expand Up @@ -253,17 +274,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

Expand Down Expand Up @@ -303,7 +326,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
Expand All @@ -319,4 +342,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
129 changes: 117 additions & 12 deletions lua/gitlab/actions/discussions/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -245,12 +244,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" }, {
Expand Down Expand Up @@ -294,15 +366,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)
Expand All @@ -327,7 +391,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
Expand All @@ -341,9 +407,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)
Expand Down Expand Up @@ -597,6 +670,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
Expand Down Expand Up @@ -809,6 +910,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
Expand Down
Loading
Loading