BREAKING CHANGE: Delta Pager + Large Refactor (#43)

BREAKING CHANGE: This MR addresses an underlying issue with the original implementation in regards to detecting line numbers for comments. 

As such, this is a major breaking change. The setup function signature has changed, please review the `README.md` for the new arguments. The delta pager has also been added as a dependency: https://github.com/dandavison/delta

There will be future work to implement a native solution for parsing changes and line numbers.
This commit is contained in:
Harrison (Harry) Cramer
2023-08-27 17:26:54 -04:00
committed by GitHub
parent ed67a03f8f
commit 19468a3d2d
19 changed files with 1273 additions and 967 deletions

111
README.md
View File

@@ -6,9 +6,7 @@ This Neovim plugin is designed to make it easy to review Gitlab MRs from within
- Approve or revoke approval for an MR
- Add or remove reviewers and assignees
- Resolve, reply to, and unresolve discussion threads
- Create, edit, delete, and reply to comments on an MR *
(*) This feature is currently in review, see https://github.com/harrisoncramer/gitlab.nvim/issues/25
- Create, edit, delete, and reply to comments on an MR
And a lot more!
@@ -18,6 +16,15 @@ https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dfd3aa8a-6fc4-4e43
- <a href="https://go.dev/">Go</a>
- <a href="https://www.gnu.org/software/make/manual/make.html">make (for install)</a>
- <a href="https://github.com/dandavison/delta">delta</a>
## Quick Start
1. Ensure Dependencies (Linux/Mac users can run the install script: ./install)
2. Add config (below)
3. Check out feature branch
4. Open Neovim
5. Run `:lua require("gitlab").review()` to open the reviewer pane, or `:lua require("gitlab").summary() to read the MR description and get started.
## Installation
@@ -29,10 +36,10 @@ return {
dependencies = {
"MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim",
"stevearc/dressing.nvim" -- Recommended but not required. Better UI for pickers.
"stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers.
enabled = true,
},
build = function () require("gitlab").build() end, -- Builds the Go binary
build = function () require("gitlab.server").build() end, -- Builds the Go binary
config = function()
require("gitlab").setup()
end,
@@ -48,7 +55,7 @@ use {
"MunifTanjim/nui.nvim",
"nvim-lua/plenary.nvim"
},
run = function() require("gitlab").build() end,
run = function() require("gitlab.server").build() end,
config = function()
require("gitlab").setup()
end,
@@ -77,35 +84,40 @@ Here is the default setup function. All of these values are optional, and if you
```lua
require("gitlab").setup({
port = 20136, -- The port of the Go server, which runs in the background
log_path = vim.fn.stdpath("cache") .. "gitlab.nvim.log", -- Log path for the Go server
keymaps = {
port = 21036, -- The port of the Go server, which runs in the background
log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server
reviewer = "delta", -- The reviewer type (only delta is currently supported)
popup = { -- The popup for comment creation, editing, and replying
exit = "<Esc>",
perform_action = "<leader>s", -- Once in normal mode, does action (like saving comment or editing description, etc)
},
discussion_tree = { -- The discussion tree that holds all comments
jump_to_location = "o", -- Jump to comment location in file
jump_to_file = "o", -- Jump to comment location in file
jump_to_reviewer = "m", -- Jump to the location in the reviewer window
edit_comment = "e", -- Edit coment
delete_comment = "dd", -- Delete comment
reply_to_comment = "r", -- Reply to comment
toggle_resolved = "p" -- Toggles the resolved status of the discussion
reply = "r", -- Reply to comment
toggle_node = "t", -- Opens or closes the discussion
toggle_resolved = "p", -- Toggles the resolved status of the discussion
position = "left", -- "top", "right", "bottom" or "left"
relative = "editor" -- Position of tree split relative to "editor" or "window"
size = "20%", -- Size of split
relative = "editor", -- Position of tree split relative to "editor" or "window"
resolved = '', -- Symbol to show next to resolved discussions
unresolved = '', -- Symbol to show next to unresolved discussions
},
review_pane = { -- Specific settings for different reviewers
delta = {
added_file = "", -- The symbol to show next to added files
modified_file = "", -- The symbol to show next to modified files
removed_file = "", -- The symbol to show next to removed files
}
},
dialogue = { -- The confirmation dialogue for deleting comments
focus_next = { "j", "<Down>", "<Tab>" },
focus_prev = { "k", "<Up>", "<S-Tab>" },
close = { "<Esc>", "<C-c>" },
submit = { "<CR>", "<Space>" },
}
},
symbols = {
resolved = '', -- Symbol to show next to resolved discussions
unresolved = '', -- Symbol to show next to unresolved discussions
}
})
```
@@ -117,7 +129,7 @@ First, check out the branch that you want to review locally.
git checkout feature-branch
```
Then open Neovim and the reviewer will be initialized. The `project_id` you specify in your configuration file must match the project_id of the Gitlab project your terminal is inside of.
Then open Neovim. The `project_id` you specify in your configuration file must match the project_id of the Gitlab project your terminal is inside of.
The `summary` command will pull down the MR description into a buffer so that you can read it. To edit the description, edit the buffer and press the `perform_action` keybinding when in normal mode (it's `<leader>s` by default):
@@ -125,25 +137,36 @@ The `summary` command will pull down the MR description into a buffer so that yo
require("gitlab").summary()
```
The `review` command will open up view of all the changes that have been made in this MR compared to the target branch in a review pane. You can leave comments on the changes.
The `approve` command will approve the merge request for the current branch:
```lua
require("gitlab").review()
require("gitlab").create_comment()
```
Gitlab groups threads of comments together into "discussions."
To display discussions for the current MR, use the `list_discussions()` command, which will show the discussions in a split window.
You can jump to the comment's location the reviewer window by using the `m` key, or the actual file with the 'j' key, when hovering over the line in the tree.
Within the discussion tree, you can delete/edit/reply to comments, or toggle them as resolved or not.
```lua
require("gitlab").list_discussions()
require("gitlab").delete_comment()
require("gitlab").edit_comment()
require("gitlab").reply()
require("gitlab").toggle_resolved()
```
You can approve or revoke approval for an MR:
```lua
require("gitlab").approve()
```
The `revoke` command will revoke approval for the merge request for the current branch:
```lua
require("gitlab").revoke()
```
The `comment` command will open up a NUI popover that will allow you to create a Gitlab comment on the current line. To send the comment, use `<leader>s` while the comment popup is open:
```lua
require("gitlab").create_comment()
```
The `add_reviewer` and `delete_reviewer` commands, as well as the `add_assignee` and `delete_assignee` functions, will let you choose from a list of users who are availble in the current project:
```lua
@@ -163,23 +186,6 @@ require("dressing").setup({
})
```
### Discussions
Gitlab groups threads of notes together into "discussions." To get a list of all the discussions for the current MR, use the `list_discussions` command. This command will open up a split view of all the comments on the current merge request. You can jump to the comment location by using the `o` key in the tree buffer, and you can reply to a thread by using the `r` keybinding in the tree buffer:
```lua
require("gitlab").list_discussions()
```
Within the discussion tree, there are several functions that you can call, however, it's better to use the keybindings provided in the setup function. If you want to call them manually, they are:
```lua
require("gitlab").delete_comment()
require("gitlab").edit_comment()
require("gitlab").reply()
require("gitlab").toggle_resolved()
```
## Keybindings
The plugin does not set up any keybindings outside of these buffers, you need to set them up yourself. Here's what I'm using:
@@ -189,8 +195,7 @@ local gitlab = require("gitlab")
vim.keymap.set("n", "<leader>gls", gitlab.summary)
vim.keymap.set("n", "<leader>glA", gitlab.approve)
vim.keymap.set("n", "<leader>glR", gitlab.revoke)
vim.keymap.set("n", "<leader>glc", gitlab.create_comment)
vim.keymap.set("n", "<leader>gld", gitlab.list_discussions)
vim.keymap.set("n", "<leader>glr", gitlab.review)
vim.keymap.set("n", "<leader>glaa", gitlab.add_assignee)
vim.keymap.set("n", "<leader>glad", gitlab.delete_assignee)
vim.keymap.set("n", "<leader>glra", gitlab.add_reviewer)
@@ -199,11 +204,13 @@ vim.keymap.set("n", "<leader>glrd", gitlab.delete_reviewer)
## Troubleshooting
**To check that the current settings of the plugin are configured correctly, please run: `:lua require("gitlab").print_settings()`**
This plugin uses a Golang server to reach out to Gitlab. It's possible that something is going wrong when starting that server or connecting with Gitlab. The Golang server runs outside of Neovim, and can be interacted with directly in order to troubleshoot. To start the server, check out your feature branch and run these commands:
```
:lua require("gitlab").build()
:lua require("gitlab").start_server()
:lua require("gitlab.server").build()
:lua require("gitlab.server").start()
```
You can directly interact with the Go server like any other process:

View File

@@ -14,7 +14,8 @@ const mrVersionsUrl = "%s/api/v4/projects/%s/merge_requests/%d/versions"
type PostCommentRequest struct {
Comment string `json:"comment"`
FileName string `json:"file_name"`
LineNumber int `json:"line_number"`
NewLine int `json:"new_line"`
OldLine int `json:"old_line"`
HeadCommitSHA string `json:"head_commit_sha"`
BaseCommitSHA string `json:"base_commit_sha"`
StartCommitSHA string `json:"start_commit_sha"`
@@ -113,18 +114,8 @@ func PostComment(w http.ResponseWriter, r *http.Request) {
BaseSHA: postCommentRequest.BaseCommitSHA,
NewPath: postCommentRequest.FileName,
OldPath: postCommentRequest.FileName,
}
/* TODO: This switch statement relates to #25, for now we are just sending both
the old line and new line but we will eventually have to fix this */
switch postCommentRequest.Type {
case "addition":
position.NewLine = postCommentRequest.LineNumber
case "subtraction":
position.OldLine = postCommentRequest.LineNumber
case "modification":
position.NewLine = postCommentRequest.LineNumber
position.OldLine = postCommentRequest.LineNumber
NewLine: postCommentRequest.NewLine,
OldLine: postCommentRequest.OldLine,
}
discussion, _, err := c.git.Discussions.CreateMergeRequestDiscussion(

View File

@@ -0,0 +1,13 @@
local job = require("gitlab.job")
local M = {}
M.approve = function()
job.run_job("/approve", "POST")
end
M.revoke = function()
job.run_job("/revoke", "POST")
end
return M

View File

@@ -1,3 +1,5 @@
-- This module is responsible for the assignment of reviewers
-- and assignees in Gitlab, those who must review an MR.
local u = require("gitlab.utils")
local job = require("gitlab.job")
local state = require("gitlab.state")
@@ -33,7 +35,7 @@ M.add_popup = function(type)
local current_ids = u.extract(current, 'id')
table.insert(current_ids, choice.id)
local json = vim.json.encode({ ids = current_ids })
job.run_job("mr/" .. type, "PUT", json, function(data)
job.run_job("/mr/" .. type, "PUT", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
state.INFO[plural] = data[plural]
end)
@@ -52,7 +54,7 @@ M.delete_popup = function(type)
if not choice then return end
local ids = u.extract(M.filter_eligible(current, { choice }), 'id')
local json = vim.json.encode({ ids = ids })
job.run_job("mr/" .. type, "PUT", json, function(data)
job.run_job("/mr/" .. type, "PUT", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
state.INFO[plural] = data[plural]
end)

View File

@@ -0,0 +1,64 @@
-- This module is responsible for creating new comments
-- in the reviewer's buffer. The reviewer will pass back
-- to this module the data required to make the API calls
local Popup = require("nui.popup")
local state = require("gitlab.state")
local job = require("gitlab.job")
local u = require("gitlab.utils")
local discussions = require("gitlab.actions.discussions")
local reviewer = require("gitlab.reviewer")
local M = {}
local comment_popup = Popup(u.create_popup_state("Comment", "40%", "60%"))
-- This function will open a comment popup in order to create a comment on the changed/updated line in the current MR
M.create_comment = function()
comment_popup:mount()
state.set_popup_keymaps(comment_popup, M.confirm_create_comment)
end
-- This function (settings.popup.perform_action) will send the comment to the Go server
M.confirm_create_comment = function(text)
local file_name, line_numbers, error = reviewer.get_location()
if error then
vim.notify(error, vim.log.levels.ERROR)
return
end
if file_name == nil then
vim.notify("Reviewer did not provide file name", vim.log.levels.ERROR)
return
end
if line_numbers == nil then
vim.notify("Reviewer did not provide line numbers of change", vim.log.levels.ERROR)
return
end
if text == nil then
vim.notify("Reviewer did not provide text of change", vim.log.levels.ERROR)
return
end
local revision = state.MR_REVISIONS[1]
local jsonTable = {
comment = text,
file_name = file_name,
old_line = line_numbers.old_line,
new_line = line_numbers.new_line,
base_commit_sha = revision.base_commit_sha,
start_commit_sha = revision.start_commit_sha,
head_commit_sha = revision.head_commit_sha,
type = "modification"
}
local json = vim.json.encode(jsonTable)
job.run_job("/comment", "POST", json, function(data)
vim.notify("Comment created")
discussions.refresh_tree()
end)
end
return M

View File

@@ -0,0 +1,475 @@
-- This module is responsible for the discussion tree. That includes things like
-- editing existing notes in the tree, replying to notes in the tree,
-- and marking discussions as resolved/unresolved.
local Popup = require("nui.popup")
local Menu = require("nui.menu")
local NuiTree = require("nui.tree")
local NuiSplit = require("nui.split")
local job = require("gitlab.job")
local u = require("gitlab.utils")
local state = require("gitlab.state")
local reviewer = require("gitlab.reviewer")
local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%"))
local reply_popup = Popup(u.create_popup_state("Reply", "80%", "80%"))
local M = {
split_visible = false,
split = nil,
split_buf = nil,
tree = nil
}
M.set_tree_keymaps = function()
vim.keymap.set('n', state.settings.discussion_tree.jump_to_file, M.jump_to_file, { buffer = true })
vim.keymap.set('n', state.settings.discussion_tree.jump_to_reviewer, M.jump_to_reviewer, { buffer = true })
vim.keymap.set('n', state.settings.discussion_tree.edit_comment, M.edit_comment, { buffer = true })
vim.keymap.set('n', state.settings.discussion_tree.delete_comment, M.delete_comment, { buffer = true })
vim.keymap.set('n', state.settings.discussion_tree.toggle_resolved, M.toggle_resolved, { buffer = true })
vim.keymap.set('n', state.settings.discussion_tree.toggle_node, M.toggle_node, { buffer = true })
vim.keymap.set('n', state.settings.discussion_tree.reply, M.reply, { buffer = true })
end
-- Opens the discussion tree, sets the keybindings,
M.toggle = function()
if M.split_visible then
M.split:hide()
M.split_visible = false
return
end
if M.split then
M.split:show()
M.split_visible = true
return
end
local split = NuiSplit({
buf_options = { modifiable = false },
relative = state.settings.discussion_tree.relative,
position = state.settings.discussion_tree.position,
size = state.settings.discussion_tree.size,
})
split:mount()
M.split = split
M.split_visible = true
M.split_buf = split.bufnr
state.discussion_buf = split.bufnr
vim.api.nvim_create_autocmd({ "QuitPre", "BufDelete", "BufUnload" }, {
buffer = split.bufnr,
callback = function()
M.split = nil
M.split_visible = false
M.split_buf = nil
end,
desc = "Handles users who close the split in non-keybinding fashion",
})
local buf = M.split.bufnr
job.run_job("/discussions", "GET", nil, function(data)
if type(data.discussions) ~= "table" then
vim.notify("No discussions for this MR", vim.log.levels.WARN)
return
end
local tree_nodes = M.add_discussions_to_table(data.discussions)
M.tree = NuiTree({ nodes = tree_nodes, bufnr = buf })
M.set_tree_keymaps()
M.tree:render()
vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown')
end)
end
-- The reply popup will mount in a window when you trigger it (settings.discussion_tree.reply) when hovering over a node in the discussion tree.
M.reply = function()
local node = M.tree:get_node()
local discussion_node = M.get_root_node(node)
local id = tostring(discussion_node.id)
reply_popup:mount()
state.set_popup_keymaps(reply_popup, M.send_reply(id))
end
-- This function will send the reply to the Go API
M.send_reply = function(discussion_id)
print(discussion_id)
return function(text)
local jsonTable = { discussion_id = discussion_id, reply = text }
local json = vim.json.encode(jsonTable)
job.run_job("/reply", "POST", json, function(data)
M.add_note_to_tree(data.note, discussion_id)
end)
end
end
-- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment
M.delete_comment = function()
local menu = Menu({
position = "50%",
size = {
width = 25,
},
border = {
style = "single",
text = {
top = "Delete Comment?",
top_align = "center",
},
},
win_options = {
winhighlight = "Normal:Normal,FloatBorder:Normal",
},
}, {
lines = {
Menu.item("Confirm"),
Menu.item("Cancel"),
},
max_width = 20,
keymap = {
focus_next = state.settings.dialogue.focus_next,
focus_prev = state.settings.dialogue.focus_prev,
close = state.settings.dialogue.close,
submit = state.settings.dialogue.submit,
},
on_submit = M.send_deletion
})
menu:mount()
end
-- This function will actually send the deletion to Gitlab
-- when you make a selection
M.send_deletion = function(item)
if item.text == "Confirm" then
local current_node = M.tree:get_node()
local note_node = M.get_note_node(current_node)
local root_node = M.get_root_node(current_node)
local note_id = note_node.is_root and root_node.root_note_id or note_node.id
local jsonTable = { discussion_id = root_node.id, note_id = note_id }
local json = vim.json.encode(jsonTable)
job.run_job("/comment", "DELETE", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
if not note_node.is_root then
M.tree:remove_node("-" .. note_id)
M.tree:render()
else
-- We are removing the root node of the discussion,
-- we need to move all the children around, the easiest way
-- to do this is to just re-render the whole tree 🤷
M.refresh_tree()
note_node:expand()
end
end)
end
end
-- This function (settings.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree
M.edit_comment = function()
local current_node = M.tree:get_node()
local note_node = M.get_note_node(current_node)
local root_node = M.get_root_node(current_node)
edit_popup:mount()
local lines = {} -- Gather all lines from immediate children that aren't note nodes
local children_ids = note_node:get_child_ids()
for _, child_id in ipairs(children_ids) do
local child_node = M.tree:get_node(child_id)
if (not child_node:has_children()) then
local line = M.tree:get_node(child_id).text
table.insert(lines, line)
end
end
local currentBuffer = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines)
state.set_popup_keymaps(edit_popup, M.send_edits(tostring(root_node.id), note_node.root_note_id or note_node.id))
end
-- This function sends the edited comment to the Go server
M.send_edits = function(discussion_id, note_id)
return function(text)
local json_table = {
discussion_id = discussion_id,
note_id = note_id,
comment = text
}
local json = vim.json.encode(json_table)
job.run_job("/comment", "PATCH", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
M.redraw_text(text)
end)
end
end
-- This comment (settings.discussion_tree.toggle_resolved) will toggle the resolved status of the current discussion and send the change to the Go server
M.toggle_resolved = function()
local note = M.tree:get_node()
if not note or not note.resolvable then return end
local json_table = {
discussion_id = note.id,
note_id = note.root_note_id,
resolved = not note.resolved,
}
local json = vim.json.encode(json_table)
job.run_job("/comment", "PATCH", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
M.redraw_resolved_status(note, not note.resolved)
end)
end
-- This function (settings.discussion_tree.jump_to_reviewer) will jump the cursor to the reviewer's location associated with the note. The implementation depends on the reviewer
M.jump_to_reviewer = function()
local file_name, new_line, old_line, error = M.get_note_location()
if error ~= nil then
vim.notify(error, vim.log.levels.ERROR)
return
end
reviewer.jump(file_name, new_line, old_line)
end
-- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab
M.jump_to_file = function()
local file_name, new_line, old_line, error = M.get_note_location()
if error ~= nil then
vim.notify(error, vim.log.levels.ERROR)
return
end
vim.cmd.tabnew()
u.jump_to_file(file_name, (new_line or old_line))
end
-- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children
M.toggle_node = function()
local node = M.tree:get_node()
if node == nil then return end
local children = node:get_child_ids()
if node == nil then return end
if node:is_expanded() then
node:collapse()
for _, child in ipairs(children) do
M.tree:get_node(child):collapse()
end
else
for _, child in ipairs(children) do
M.tree:get_node(child):expand()
end
node:expand()
end
M.tree:render()
end
--
-- 🌲 Helper Functions
--
M.redraw_resolved_status = function(note, mark_resolved)
local current_text = M.tree.nodes.by_id["-" .. note.id].text
local target = mark_resolved and 'resolved' or 'unresolved'
local current = mark_resolved and 'unresolved' or 'resolved'
local function set_property(key, val)
M.tree.nodes.by_id["-" .. note.id][key] = val
end
local has_symbol = function(s)
return state.settings.discussion_tree[s] ~= nil and state.settings.discussion_tree[s] ~= ''
end
set_property('resolved', mark_resolved)
if not has_symbol(current) and not has_symbol(target) then return end
if not has_symbol(current) and has_symbol(target) then
set_property('text', (current_text .. " " .. state.settings.discussion_tree[target]))
elseif has_symbol(current) and not has_symbol(target) then
set_property('text', u.remove_last_chunk(current_text))
else
set_property('text', (u.remove_last_chunk(current_text) .. " " .. state.settings.discussion_tree[target]))
end
M.tree:render()
end
M.redraw_text = function(text)
local current_node = M.tree:get_node()
local note_node = M.get_note_node(current_node)
local childrenIds = note_node:get_child_ids()
for _, value in ipairs(childrenIds) do
M.tree:remove_node(value)
end
local newNoteTextNodes = {}
for bodyLine in text:gmatch("[^\n]+") do
table.insert(newNoteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {}))
end
M.tree:set_nodes(newNoteTextNodes, "-" .. note_node.id)
M.tree:render()
end
M.get_root_node = function(node)
if (not node.is_root) then
local parent_id = node:get_parent_id()
return M.get_root_node(M.tree:get_node(parent_id))
else
return node
end
end
M.get_note_node = function(node)
if (not node.is_note) then
local parent_id = node:get_parent_id()
if parent_id == nil then return node end
return M.get_note_node(M.tree:get_node(parent_id))
else
return node
end
end
local attach_uuid = function(str)
return { text = str, id = u.uuid() }
end
M.build_note_body = function(note, resolve_info)
local text_nodes = {}
for bodyLine in note.body:gmatch("[^\n]+") do
local line = attach_uuid(bodyLine)
table.insert(text_nodes, NuiTree.Node({
new_line = note.position.new_line,
old_line = note.position.old_line,
text = line.text,
id = line.id,
is_body = true
}, {}))
end
local resolve_symbol = ''
if resolve_info ~= nil and resolve_info.resolvable then
resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved or
state.settings.discussion_tree.unresolved
end
local noteHeader = "@" .. note.author.username .. " " .. u.format_date(note.created_at) .. " " .. resolve_symbol
return noteHeader, text_nodes
end
M.build_note = function(note, resolve_info)
local text, text_nodes = M.build_note_body(note, resolve_info)
local note_node = NuiTree.Node({
text = text,
id = note.id,
file_name = note.position.new_path,
new_line = note.position.new_line,
old_line = note.position.old_line,
is_note = true,
}, text_nodes)
return note_node, text, text_nodes
end
M.add_note_to_tree = function(note, discussion_id)
local note_node = M.build_note(note)
note_node:expand()
M.tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil)
M.tree:render()
vim.notify("Sent reply!", vim.log.levels.INFO)
end
M.refresh_tree = function()
job.run_job("/discussions", "GET", nil, function(data)
if type(data.discussions) ~= "table" then
vim.notify("No discussions for this MR")
return
end
if not M.split_buf or (vim.fn.bufwinid(M.split_buf) == -1) then return end
vim.api.nvim_buf_set_option(M.split_buf, 'modifiable', true)
vim.api.nvim_buf_set_option(M.split_buf, 'readonly', false)
vim.api.nvim_buf_set_lines(M.split_buf, 0, -1, false, {})
vim.api.nvim_buf_set_option(M.split_buf, 'readonly', true)
vim.api.nvim_buf_set_option(M.split_buf, 'modifiable', false)
local tree_nodes = M.add_discussions_to_table(data.discussions)
M.tree = NuiTree({ nodes = tree_nodes, bufnr = M.split_buf })
M.set_tree_keymaps()
M.tree:render()
vim.api.nvim_buf_set_option(M.split_buf, 'filetype', 'markdown')
end)
end
M.add_discussions_to_table = function(discussions)
local t = {}
for _, discussion in ipairs(discussions) do
local discussion_children = {}
-- These properties are filled in by the first note
local root_text = ''
local root_note_id = ''
local root_file_name = ''
local root_id = 0
local root_text_nodes = {}
local resolvable = false
local resolved = false
local root_new_line = nil
local root_old_line = nil
for j, note in ipairs(discussion.notes) do
if j == 1 then
__, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable })
root_file_name = note.position.new_path
root_new_line = note.position.new_line
root_old_line = note.position.old_line
root_id = discussion.id
root_note_id = note.id
resolvable = note.resolvable
resolved = note.resolved
else -- Otherwise insert it as a child node...
local note_node = M.build_note(note)
table.insert(discussion_children, note_node)
end
end
-- Creates the first node in the discussion, and attaches children
local body = u.join_tables(root_text_nodes, discussion_children)
local root_node = NuiTree.Node({
text = root_text,
is_note = true,
is_root = true,
id = root_id,
root_note_id = root_note_id,
file_name = root_file_name,
new_line = root_new_line,
old_line = root_old_line,
resolvable = resolvable,
resolved = resolved
}, body)
table.insert(t, root_node)
end
return t
end
M.get_note_location = function()
local node = M.tree:get_node()
if node == nil then return nil, nil, nil, "Could not get node" end
local discussion_node = M.get_root_node(node)
if discussion_node == nil then return nil, nil, nil, "Could not get discussion node" end
return discussion_node.file_name, discussion_node.new_line, discussion_node.old_line
end
return M

View File

@@ -1,12 +1,15 @@
-- This module is responsible for the MR description
-- This lets the user open the description in a popup and
-- send edits to the description back to Gitlab
local Popup = require("nui.popup")
local job = require("gitlab.job")
local state = require("gitlab.state")
local Popup = require("nui.popup")
local u = require("gitlab.utils")
local keymaps = require("gitlab.keymaps")
local descriptionPopup = Popup(u.create_popup_state("Loading Description...", "80%", "80%"))
local M = {}
-- The MR description will mount in a popup when this funciton is called
local descriptionPopup = Popup(u.create_popup_state("Loading Description...", "80%", "80%"))
-- The function will render the MR description in a popup
M.summary = function()
descriptionPopup:mount()
local currentBuffer = vim.api.nvim_get_current_buf()
@@ -20,7 +23,7 @@ M.summary = function()
vim.schedule(function()
vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines)
descriptionPopup.border:set_text("top", title, "center")
keymaps.set_popup_keymaps(descriptionPopup, M.edit_description)
state.set_popup_keymaps(descriptionPopup, M.edit_description)
end)
end
@@ -28,7 +31,7 @@ end
M.edit_description = function(text)
local jsonTable = { description = text }
local json = vim.json.encode(jsonTable)
job.run_job("mr/description", "PUT", json, function(data)
job.run_job("/mr/description", "PUT", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
state.INFO.description = data.mr.description
end)

67
lua/gitlab/async.lua Normal file
View File

@@ -0,0 +1,67 @@
-- This module is responsible for calling APIs in sequence. It provides
-- an abstraction around the APIs that lets us ensure state.
local server = require("gitlab.server")
local job = require("gitlab.job")
local state = require("gitlab.state")
local M = {}
Async = {
cb = nil
}
function Async:new(o)
o = o or {}
setmetatable(o, self)
self.__index = self
return o
end
function Async:init(cb)
self.cb = cb
end
function Async:fetch(dependencies, i)
if i > #dependencies then
self:cb()
return
end
local dependency = dependencies[i]
-- Do not call endpoint unless refresh is required
if state[dependency.state] ~= nil and not dependency.refresh then
self:fetch(dependencies, i + 1)
return
end
job.run_job(dependency.endpoint, "GET", dependency.body, function(data)
state[dependency.state] = data[dependency.key]
self:fetch(dependencies, i + 1)
end)
end
-- Will call APIs in sequence and set global state
M.sequence = function(dependencies, cb)
return function()
local handler = Async:new()
handler:init(cb)
if not state.is_gitlab_project then
vim.notify("The gitlab.nvim state was not set. Do you have a .gitlab.nvim file configured?", vim.log.levels.ERROR)
return
end
if state.go_server_running then
handler:fetch(dependencies, 1)
return
end
server.start_server(function()
state.go_server_running = true
handler:fetch(dependencies, 1)
end)
end
end
return M

View File

@@ -1,250 +0,0 @@
local Menu = require("nui.menu")
local NuiTree = require("nui.tree")
local Popup = require("nui.popup")
local job = require("gitlab.job")
local state = require("gitlab.state")
local u = require("gitlab.utils")
local discussions = require("gitlab.discussions")
local keymaps = require("gitlab.keymaps")
local M = {}
local comment_popup = Popup(u.create_popup_state("Comment", "40%", "60%"))
local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%"))
-- This function will open a comment popup in order to create a comment on the changed/updated line in the current MR
M.create_comment = function()
comment_popup:mount()
keymaps.set_popup_keymaps(comment_popup, M.confirm_create_comment)
end
-- This function (keymaps.popup.perform_action) will send the comment to the Go server
M.confirm_create_comment = function(text)
local relative_file_path = u.get_relative_file_path()
local current_line_number = u.get_current_line_number()
if relative_file_path == nil then return end
-- If leaving a comment on a deleted line, get hash value + proper filename
local sha = ""
local is_base_file = relative_file_path:find(".git")
if is_base_file then -- We are looking at a deletion.
local _, path = u.split_diff_view_filename(relative_file_path)
relative_file_path = path
sha = M.find_deletion_commit(path)
if sha == "" then
return
end
end
-- TODO: How can we know whether to specify that the comment is on a line that has been modified,
-- added, or deleted? Additionally, how will we know which line number to send?
-- We need an intelligent way of getting this information so that we can send it to the comment
-- creation endpoint, relates to Issue #25: https://github.com/harrisoncramer/gitlab.nvim/issues/25
local revision = state.MR_REVISIONS[1]
local jsonTable = {
comment = text,
file_name = relative_file_path,
line_number = current_line_number,
base_commit_sha = revision.base_commit_sha,
start_commit_sha = revision.start_commit_sha,
head_commit_sha = revision.head_commit_sha,
type = "modification"
}
local json = vim.json.encode(jsonTable)
job.run_job("comment", "POST", json, function(data)
vim.notify("Comment created")
discussions.refresh_tree()
end)
end
-- This function (keymaps.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment
M.delete_comment = function()
local menu = Menu({
position = "50%",
size = {
width = 25,
},
border = {
style = "single",
text = {
top = "Delete Comment?",
top_align = "center",
},
},
win_options = {
winhighlight = "Normal:Normal,FloatBorder:Normal",
},
}, {
lines = {
Menu.item("Confirm"),
Menu.item("Cancel"),
},
max_width = 20,
keymap = {
focus_next = state.keymaps.dialogue.focus_next,
focus_prev = state.keymaps.dialogue.focus_prev,
close = state.keymaps.dialogue.close,
submit = state.keymaps.dialogue.submit,
},
on_submit = M.send_deletion
})
menu:mount()
end
-- This function will actually send the deletion to Gitlab
-- when you make a selection
M.send_deletion = function(item)
if item.text == "Confirm" then
local current_node = state.tree:get_node()
local note_node = discussions.get_note_node(current_node)
local root_node = discussions.get_root_node(current_node)
local note_id = note_node.is_root and root_node.root_note_id or note_node.id
local jsonTable = { discussion_id = root_node.id, note_id = note_id }
local json = vim.json.encode(jsonTable)
job.run_job("comment", "DELETE", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
if not note_node.is_root then
state.tree:remove_node("-" .. note_id)
state.tree:render()
else
-- We are removing the root node of the discussion,
-- we need to move all the children around, the easiest way
-- to do this is to just re-render the whole tree 🤷
discussions.refresh_tree()
note_node:expand()
end
end)
end
end
-- This function (keymaps.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree
M.edit_comment = function()
local current_node = state.tree:get_node()
local note_node = discussions.get_note_node(current_node)
local root_node = discussions.get_root_node(current_node)
edit_popup:mount()
local lines = {} -- Gather all lines from immediate children that aren't note nodes
local children_ids = note_node:get_child_ids()
for _, child_id in ipairs(children_ids) do
local child_node = state.tree:get_node(child_id)
if (not child_node:has_children()) then
local line = state.tree:get_node(child_id).text
table.insert(lines, line)
end
end
local currentBuffer = vim.api.nvim_get_current_buf()
vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines)
keymaps.set_popup_keymaps(edit_popup, M.send_edits(tostring(root_node.id), note_node.root_note_id or note_node.id))
end
-- This function sends the edited comment to the Go server
M.send_edits = function(discussion_id, note_id)
return function(text)
local json_table = {
discussion_id = discussion_id,
note_id = note_id,
comment = text
}
local json = vim.json.encode(json_table)
job.run_job("comment", "PATCH", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
M.redraw_text(text)
end)
end
end
-- This comment (keymaps.discussion_tree.toggle_resolved) will toggle the resolved status of the current discussion and send the change to the Go server
M.toggle_resolved = function()
local note = state.tree:get_node()
if not note.resolvable then return end
local json_table = {
discussion_id = note.id,
note_id = note.root_note_id,
resolved = not note.resolved,
}
local json = vim.json.encode(json_table)
job.run_job("comment", "PATCH", json, function(data)
vim.notify(data.message, vim.log.levels.INFO)
M.update_resolved_status(note, not note.resolved)
end)
end
-- Helpers
M.find_deletion_commit = function(file)
local current_line = vim.api.nvim_get_current_line()
local command = string.format("git log -S '%s' %s", current_line, file)
local handle = io.popen(command)
local output = handle:read("*line")
if output == nil then
vim.notify("Error reading SHA of deletion commit", vim.log.levels.ERROR)
return ""
end
handle:close()
local words = {}
for word in output:gmatch("%S+") do
table.insert(words, word)
end
return words[2]
end
M.update_resolved_status = function(note, mark_resolved)
local current_text = state.tree.nodes.by_id["-" .. note.id].text
local target = mark_resolved and 'resolved' or 'unresolved'
local current = mark_resolved and 'unresolved' or 'resolved'
local function set_property(key, val)
state.tree.nodes.by_id["-" .. note.id][key] = val
end
local has_symbol = function(s)
return state.SYMBOLS[s] ~= nil and state.SYMBOLS[s] ~= ''
end
set_property('resolved', mark_resolved)
if not has_symbol(current) and not has_symbol(target) then return end
if not has_symbol(current) and has_symbol(target) then
set_property('text', (current_text .. " " .. state.SYMBOLS[target]))
elseif has_symbol(current) and not has_symbol(target) then
set_property('text', u.remove_last_chunk(current_text))
else
set_property('text', (u.remove_last_chunk(current_text) .. " " .. state.SYMBOLS[target]))
end
state.tree:render()
end
M.redraw_text = function(text)
local current_node = state.tree:get_node()
local note_node = discussions.get_note_node(current_node)
local childrenIds = note_node:get_child_ids()
for _, value in ipairs(childrenIds) do
state.tree:remove_node(value)
end
local newNoteTextNodes = {}
for bodyLine in text:gmatch("[^\n]+") do
table.insert(newNoteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {}))
end
state.tree:set_nodes(newNoteTextNodes, "-" .. note_node.id)
state.tree:render()
local buf = vim.api.nvim_get_current_buf()
u.darken_metadata(buf, '')
end
return M

View File

@@ -1,265 +0,0 @@
local u = require("gitlab.utils")
local NuiTree = require("nui.tree")
local NuiSplit = require("nui.split")
local job = require("gitlab.job")
local state = require("gitlab.state")
local Popup = require("nui.popup")
local keymaps = require("gitlab.keymaps")
local M = {}
-- Places all of the discussions into a readable tree
-- in a split window
M.list_discussions = function()
job.run_job("discussions", "GET", nil, function(data)
if type(data.discussions) ~= "table" then
vim.notify("No discussions for this MR")
return
end
local splitState = state.DISCUSSION.SPLIT
splitState.buf_options = { modifiable = false }
local split = NuiSplit(splitState)
split:mount()
local buf = split.bufnr
state.SPLIT_BUF = buf
local tree_nodes = M.add_discussions_to_table(data.discussions)
state.tree = NuiTree({ nodes = tree_nodes, bufnr = buf })
M.set_tree_keymaps(buf)
state.tree:render()
vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown')
u.darken_metadata(buf, '')
end)
end
-- The reply popup will mount in a window when you trigger it (keymaps.discussion_tree.reply_to_comment) when hovering over a node in the discussion tree.
local replyPopup = Popup(u.create_popup_state("Reply", "80%", "80%"))
M.reply = function(discussion_id)
replyPopup:mount()
keymaps.set_popup_keymaps(replyPopup, M.send_reply(discussion_id))
end
-- This function will send the reply to the Go API
M.send_reply = function(discussion_id)
return function(text)
local jsonTable = { discussion_id = discussion_id, reply = text }
local json = vim.json.encode(jsonTable)
job.run_job("reply", "POST", json, function(data)
M.add_note_to_tree(data.note, discussion_id)
end)
end
end
-- This function (keymaps.discussion_tree.jump_to_location) will
-- jump you to the file and line where the comment was left
M.jump_to_file = function()
local node = state.tree:get_node()
if node == nil then return end
local wins = vim.api.nvim_list_wins()
local discussion_win = vim.api.nvim_get_current_win()
for _, winId in ipairs(wins) do
if winId ~= discussion_win then
vim.api.nvim_set_current_win(winId)
end
end
local discussion_node = M.get_root_node(node)
u.jump_to_file(discussion_node.file_name, discussion_node.line_number)
end
M.set_tree_keymaps = function(buf)
vim.keymap.set('n', state.keymaps.discussion_tree.jump_to_location, function()
M.jump_to_file()
end, { buffer = true })
vim.keymap.set('n', state.keymaps.discussion_tree.edit_comment, function()
require("gitlab.comment").edit_comment()
end, { buffer = true })
vim.keymap.set('n', state.keymaps.discussion_tree.delete_comment, function()
require("gitlab.comment").delete_comment()
end, { buffer = true })
vim.keymap.set('n', state.keymaps.discussion_tree.toggle_resolved, function()
require("gitlab.comment").toggle_resolved()
end, { buffer = true })
-- Expands/collapses the current node
vim.keymap.set('n', state.keymaps.discussion_tree.toggle_node, function()
local node = state.tree:get_node()
if node == nil then return end
local children = node:get_child_ids()
if node == nil then return end
if node:is_expanded() then
node:collapse()
for _, child in ipairs(children) do
state.tree:get_node(child):collapse()
end
else
for _, child in ipairs(children) do
state.tree:get_node(child):expand()
end
node:expand()
end
state.tree:render()
u.darken_metadata(buf, '')
end,
{ buffer = true })
vim.keymap.set('n', 'r', function()
local node = state.tree:get_node()
if node == nil then return end
local discussion_node = M.get_root_node(node)
M.reply(tostring(discussion_node.id))
end, { buffer = true })
end
--
-- 🌲 Helper Functions
--
M.get_root_node = function(node)
if (not node.is_root) then
local parent_id = node:get_parent_id()
return M.get_root_node(state.tree:get_node(parent_id))
else
return node
end
end
M.get_note_node = function(node)
if (not node.is_note) then
local parent_id = node:get_parent_id()
if parent_id == nil then return node end
return M.get_note_node(state.tree:get_node(parent_id))
else
return node
end
end
M.build_note_body = function(note, resolve_info)
local text_nodes = {}
for bodyLine in note.body:gmatch("[^\n]+") do
local line = u.attach_uuid(bodyLine)
table.insert(text_nodes, NuiTree.Node({
text = line.text,
id = line.id,
is_body = true
}, {}))
end
local resolve_symbol = ''
if resolve_info ~= nil and resolve_info.resolvable then
resolve_symbol = resolve_info.resolved and state.SYMBOLS.resolved or state.SYMBOLS.unresolved
end
local noteHeader = "@" .. note.author.username .. " " .. u.format_date(note.created_at) .. " " .. resolve_symbol
return noteHeader, text_nodes
end
M.build_note = function(note, resolve_info)
local text, text_nodes = M.build_note_body(note, resolve_info)
local line_number = note.position.new_line or note.position.old_line
local note_node = NuiTree.Node({
text = text,
id = note.id,
file_name = note.position.new_path,
line_number = line_number,
is_note = true,
}, text_nodes)
return note_node, text, text_nodes
end
M.add_note_to_tree = function(note, discussion_id)
local note_node = M.build_note(note)
note_node:expand()
state.tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil)
state.tree:render()
local buf = vim.api.nvim_get_current_buf()
u.darken_metadata(buf, '')
vim.notify("Sent reply!", vim.log.levels.INFO)
end
M.refresh_tree = function()
job.run_job("discussions", "GET", nil, function(data)
if type(data.discussions) ~= "table" then
vim.notify("No discussions for this MR")
return
end
if not state.SPLIT_BUF or (vim.fn.bufwinid(state.SPLIT_BUF) == -1) then return end
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'modifiable', true)
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'readonly', false)
vim.api.nvim_buf_set_lines(state.SPLIT_BUF, 0, -1, false, {})
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'readonly', true)
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'modifiable', false)
local tree_nodes = M.add_discussions_to_table(data.discussions)
state.tree = NuiTree({ nodes = tree_nodes, bufnr = state.SPLIT_BUF })
M.set_tree_keymaps(state.SPLIT_BUF)
state.tree:render()
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'filetype', 'markdown')
u.darken_metadata(state.SPLIT_BUF, '')
end)
end
M.add_discussions_to_table = function(discussions)
local t = {}
for _, discussion in ipairs(discussions) do
local discussion_children = {}
-- These properties are filled in by the first note
local root_text = ''
local root_note_id = ''
local root_line_number = 0
local root_file_name = ''
local root_id = 0
local root_text_nodes = {}
local resolvable = false
local resolved = false
for j, note in ipairs(discussion.notes) do
if j == 1 then
__, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable })
root_file_name = note.position.new_path
root_line_number = note.position.new_line or note.position.old_line
root_id = discussion.id
root_note_id = note.id
resolvable = note.resolvable
resolved = note.resolved
else -- Otherwise insert it as a child node...
local note_node = M.build_note(note)
table.insert(discussion_children, note_node)
end
end
-- Creates the first node in the discussion, and attaches children
local body = u.join_tables(root_text_nodes, discussion_children)
local root_node = NuiTree.Node({
text = root_text,
is_note = true,
is_root = true,
id = root_id,
root_note_id = root_note_id,
file_name = root_file_name,
line_number = root_line_number,
resolvable = resolvable,
resolved = resolved
}, body)
table.insert(t, root_node)
end
return t
end
return M

View File

@@ -1,200 +1,42 @@
local state = require("gitlab.state")
local discussions = require("gitlab.discussions")
local summary = require("gitlab.summary")
local assignees_and_reviewers = require("gitlab.assignees_and_reviewers")
local keymaps = require("gitlab.keymaps")
local comment = require("gitlab.comment")
local job = require("gitlab.job")
local u = require("gitlab.utils")
local async = require("gitlab.async")
local server = require("gitlab.server")
local state = require("gitlab.state")
local reviewer = require("gitlab.reviewer")
local discussions = require("gitlab.actions.discussions")
local summary = require("gitlab.actions.summary")
local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers")
local comment = require("gitlab.actions.comment")
local approvals = require("gitlab.actions.approvals")
local M = {}
M.args = nil
local info = state.dependencies.info
local project_members = state.dependencies.project_members
local revisions = state.dependencies.revisions
-- Builds the binary (if not built) and sets the plugin arguments
M.setup = function(args)
if args == nil then args = {} end
local file_path = u.current_file_path()
local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h:h")
state.BIN_PATH = parent_dir
state.BIN = parent_dir .. "/bin"
local binary_exists = vim.loop.fs_stat(state.BIN)
if binary_exists == nil then M.build() end
if not M.setPluginConfiguration(args) then return end -- Return if not a valid gitlab project
M.args = args -- The ensureState function won't start without args
end
-- Function names prefixed with "ensure" will ensure the plugin's state
-- is initialized prior to running other calls. These functions run
-- API calls if the state isn't initialized, which will set state containing
-- information that's necessary for other API calls, like description,
-- author, reviewer, etc.
M.ensureState = function(callback)
return function()
if not M.args then
vim.notify("The gitlab.nvim state was not set. Do you have a .gitlab.nvim file configured?", vim.log.levels.ERROR)
return
end
if M.go_server_running then
callback()
return
end
-- Once the Go binary has go_server_running, call the info endpoint to set global state
M.start_server(function()
keymaps.set_keymap_keys(M.args.keymaps)
M.go_server_running = true
job.run_job("info", "GET", nil, function(data)
state.INFO = data.info
callback()
end)
end)
end
end
-- This will start the Go server and call the callback provided
M.go_server_running = false
M.start_server = function(callback)
local command = state.BIN
.. " "
.. state.PROJECT_ID
.. " "
.. state.GITLAB_URL
.. " "
.. state.PORT
.. " "
.. state.AUTH_TOKEN
.. " "
.. state.LOG_PATH
vim.fn.jobstart(command, {
on_stdout = function(job_id)
if job_id <= 0 then
vim.notify("Could not start gitlab.nvim binary", vim.log.levels.ERROR)
elseif callback ~= nil then
callback()
end
return {
setup = function(args)
server.build() -- Builds the Go binary if it doesn't exist
state.setPluginConfiguration() -- Sets configuration from `.gitlab.nvim` file
state.merge_settings(args) -- Sets keymaps and other settings from setup function
reviewer.init() -- Picks and initializes reviewer (default is Delta)
end,
on_stderr = function(_, errors)
local err_msg = ''
for _, err in ipairs(errors) do
if err ~= "" and err ~= nil then
err_msg = err_msg .. err .. "\n"
end
end
vim.notify(err_msg, vim.log.levels.ERROR)
end
})
end
M.ensureProjectMembers = function(callback)
return function()
if type(state.PROJECT_MEMBERS) ~= "table" then
job.run_job("members", "GET", nil, function(data)
state.PROJECT_MEMBERS = data.ProjectMembers
callback()
end)
else
callback()
end
end
end
M.ensureRevisions = function(callback)
return function()
if type(state.MR_REVISIONS) ~= "table" then
job.run_job("mr/revisions", "GET", nil, function(data)
state.MR_REVISIONS = data.Revisions
callback()
end)
else
callback()
end
end
end
-- Builds the Go binary
M.build = function()
local command = string.format("cd %s && make", state.BIN_PATH)
local installCode = os.execute(command .. "> /dev/null")
if installCode ~= 0 then
vim.notify("Could not install gitlab.nvim!", vim.log.levels.ERROR)
return false
end
return true
end
-- Initializes state for the project based on the arguments
-- provided in the `.gitlab.nvim` file per project, and the args provided in the setup function
M.setPluginConfiguration = function(args)
local config_file_path = vim.fn.getcwd() .. "/.gitlab.nvim"
local config_file_content = u.read_file(config_file_path)
if config_file_content == nil then
return false
end
local file = assert(io.open(config_file_path, "r"))
local properties = {}
for line in file:lines() do
for key, value in string.gmatch(line, "(.-)=(.-)$") do
properties[key] = value
end
end
state.PROJECT_ID = properties.project_id
state.AUTH_TOKEN = properties.auth_token or os.getenv("GITLAB_TOKEN")
state.GITLAB_URL = properties.gitlab_url or "https://gitlab.com"
if state.AUTH_TOKEN == nil then
error("Missing authentication token for Gitlab")
end
if state.PROJECT_ID == nil then
error("Missing project ID in .gitlab.nvim file.")
end
if type(tonumber(state.PROJECT_ID)) ~= "number" then
error("The .gitlab.nvim project file's 'project_id' must be number")
end
-- Configuration for the plugin, such as port of server, layout, etc
state.PORT = args.port or 21036
state.LOG_PATH = args.log_path or (vim.fn.stdpath("cache") .. "/gitlab.nvim.log")
state.DISCUSSION = {
SPLIT = {
relative = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.relative or "editor",
position = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.position or "left",
size = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.size or "20%",
-- Global Actions 🌎
summary = async.sequence({ info }, summary.summary),
approve = async.sequence({ info }, approvals.approve),
revoke = async.sequence({ info }, approvals.revoke),
add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer),
delete_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.delete_reviewer),
add_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.add_assignee),
delete_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.delete_assignee),
create_comment = async.sequence({ info, revisions }, comment.create_comment),
review = async.sequence({ u.merge(info, { refresh = true }) }, function() reviewer.open() end),
-- Discussion Tree Actions 🌴
toggle_discussions = async.sequence({ info }, discussions.toggle),
edit_comment = async.sequence({ info }, discussions.edit_comment),
delete_comment = async.sequence({ info }, discussions.delete_comment),
toggle_resolved = async.sequence({ info }, discussions.toggle_resolved),
reply = async.sequence({ info }, discussions.reply),
-- Other functions 🤷
state = state,
print_settings = state.print_settings,
}
}
state.SYMBOLS = {
resolved = (args.symbols and args.symbols.resolved or ''),
unresolved = (args.symbols and args.symbols.unresolved or '')
}
return true
end
-- Root Module Scope
-- These functions are exposed when you call require("gitlab").some_function() from Neovim
-- and are bound to keymaps provided in the setup function
M.summary = M.ensureState(summary.summary)
M.approve = M.ensureState(function() job.run_job("approve", "POST") end)
M.revoke = M.ensureState(function() job.run_job("revoke", "POST") end)
M.list_discussions = M.ensureState(discussions.list_discussions)
M.create_comment = M.ensureState(M.ensureRevisions(comment.create_comment))
M.edit_comment = M.ensureState(comment.edit_comment)
M.delete_comment = M.ensureState(comment.delete_comment)
M.toggle_resolved = M.ensureState(comment.toggle_resolved)
M.reply = M.ensureState(discussions.reply)
M.add_reviewer = M.ensureState(M.ensureProjectMembers(assignees_and_reviewers.add_reviewer))
M.delete_reviewer = M.ensureState(M.ensureProjectMembers(assignees_and_reviewers.delete_reviewer))
M.add_assignee = M.ensureState(M.ensureProjectMembers(assignees_and_reviewers.add_assignee))
M.delete_assignee = M.ensureState(M.ensureProjectMembers(assignees_and_reviewers.delete_assignee))
M.state = state
return M

View File

@@ -1,11 +1,11 @@
-- This module is responsible for making API calls to the Go server and
-- running the callbacks associated with those jobs when the JSON is returned
local Job = require("plenary.job")
local state = require("gitlab.state")
local M = {}
-- This function is responsible for making API calls to the Go server and
-- running the callbacks associated with those jobs when the JSON is returned
M.run_job = function(endpoint, method, body, callback)
local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s/", state.PORT) .. endpoint }
local state = require("gitlab.state")
local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s", state.settings.port) .. endpoint }
if body ~= nil then
table.insert(args, 1, "-d")
@@ -21,7 +21,13 @@ M.run_job = function(endpoint, method, body, callback)
on_stdout = function(_, output)
vim.defer_fn(function()
local data_ok, data = pcall(vim.json.decode, output)
if data_ok and data ~= nil then
if not data_ok then
local msg = string.format("Failed to parse JSON from %s endpoint", endpoint)
if (type(output) == "string") then msg = string.format(msg .. ", got: '%s'", output) end
vim.notify(string.format(msg, endpoint, output), vim.log.levels.WARN)
return
end
if data ~= nil then
local status = (tonumber(data.status) >= 200 and tonumber(data.status) < 300) and "success" or "error"
if status == "success" and callback ~= nil then
callback(data)
@@ -39,7 +45,14 @@ M.run_job = function(endpoint, method, body, callback)
vim.defer_fn(function()
vim.notify("Could not run command!", vim.log.levels.ERROR)
end, 0)
end,
on_exit = function(msg, status)
vim.defer_fn(function()
if status ~= 0 then
vim.notify(string.format("Go server exited with non-zero code: %d", status), vim.log.levels.ERROR)
end
end, 0)
end,
}):start()
end

View File

@@ -1,22 +0,0 @@
local u = require("gitlab.utils")
local state = require("gitlab.state")
local M = {}
-- Sets the keymaps for the popup window that's used for replies, the summary, etc
M.set_popup_keymaps = function(popup, action)
vim.keymap.set('n', state.keymaps.popup.exit, function() u.exit(popup) end, { buffer = true })
if action ~= nil then
vim.keymap.set('n', state.keymaps.popup.perform_action, function()
local text = u.get_buffer_text(popup.bufnr)
popup:unmount()
action(text)
end, { buffer = true })
end
end
M.set_keymap_keys = function(keyTable)
if keyTable == nil then return end
state.keymaps = u.merge_tables(state.keymaps, keyTable)
end
return M

View File

@@ -0,0 +1,202 @@
-- This Module contains all of the code specific to the Delta reviewer.
local state = require("gitlab.state")
local u = require("gitlab.utils")
local M = {
bufnr = nil
}
-- Public Functions
-- These functions are exposed externally and are used
-- when the reviewer is consumed by other code. They must follow the specification
-- outlined in the reviewer/init.lua file
M.open = function()
local current_buf = vim.api.nvim_get_current_buf()
if current_buf == state.discussion_buf then
vim.api.nvim_command("wincmd w")
end
vim.cmd.enew()
if M.bufnr ~= nil then
vim.api.nvim_set_current_buf(M.bufnr)
return
end
local term_command_template =
"GIT_PAGER='delta --hunk-header-style omit --line-numbers --paging never --file-added-label %s --file-removed-label %s --file-modified-label %s' git diff %s...HEAD"
local term_command = string.format(term_command_template,
state.settings.review_pane.delta.added_file,
state.settings.review_pane.delta.removed_file,
state.settings.review_pane.delta.modified_file,
state.INFO.target_branch)
vim.fn.termopen(term_command) -- Calls delta and sends the output to the currently blank buffer
M.bufnr = vim.api.nvim_get_current_buf()
end
M.jump = function(file_name, new_line, old_line)
local linnr, error = M.get_jump_location(file_name, new_line, old_line)
if error ~= nil then
vim.notify(error, vim.log.levels.ERROR)
return
end
vim.api.nvim_command("wincmd w")
u.jump_to_buffer(M.bufnr, linnr)
end
M.get_location = function()
if M.bufnr == nil then return nil, nil, "Delta reviewer must be initialized first" end
local bufnr = vim.api.nvim_get_current_buf()
if bufnr ~= M.bufnr then return nil, nil, "Line location can only be determined within reviewer window" end
local line_num = u.get_current_line_number()
local file_name = M.get_file_from_review_buffer(u.get_current_line_number())
local range, error = M.get_review_buffer_range(file_name)
if error ~= nil then return nil, nil, error end
if range == nil then return nil, nil, "Review buffer range could not be identified" end
-- In case the comment is left on a line without change information, we
-- iterate backward until we find it within the range of the changes
local current_line_changes = nil
local num = line_num
while range ~= nil and num >= range[1] and current_line_changes == nil do
local content = u.get_line_content(M.bufnr, num)
local change_nums = M.get_change_nums(content)
current_line_changes = change_nums
num = num - 1
end
if current_line_changes == nil then
return nil, nil, "Could not find current line change information"
end
local new_line_num = line_num + 1
local next_line_changes = nil
while range ~= nil and new_line_num <= range[2] and next_line_changes == nil do
local content = u.get_line_content(M.bufnr, new_line_num)
local change_nums = M.get_change_nums(content)
next_line_changes = change_nums
new_line_num = new_line_num + 1
end
if next_line_changes == nil then
return nil, nil, "Could not find next line change information"
end
-- This is actually a modified line if these conditions are met
if (current_line_changes.old_line and not current_line_changes.new_line and not next_line_changes.old_line and next_line_changes.new_line) then
do
current_line_changes = {
old_line = current_line_changes.old,
new_line = next_line_changes.new_line
}
end
end
return file_name, current_line_changes
end
-- Helper Functions 🤝
-- These functions are not exported and should be private
-- to the delta reviewer, they are used to support the public functions
M.get_jump_location = function(file_name, new_line, old_line)
local range, error = M.get_review_buffer_range(file_name)
if error ~= nil then return nil, error end
if range == nil then return nil, "Review buffer range could not be identified" end
local linnr = nil
local lines = M.get_review_buffer_lines(range)
for _, line in ipairs(lines) do
local line_data = M.get_change_nums(line.line_content)
if old_line == line_data.old_line and new_line == line_data.new_line then
linnr = line.line_number
break
end
end
if linnr == nil then return nil, "Could not find matching line" end
return linnr, nil
end
M.get_file_from_review_buffer = function(linenr)
for i = linenr, 0, -1 do
local line_content = u.get_line_content(M.bufnr, i)
if M.starts_with_file_symbol(line_content) then
local file_name = u.get_last_chunk(line_content)
return file_name
end
end
end
M.get_change_nums = function(line)
local data, _ = line:match("(.-)" .. "" .. "(.*)")
local line_data = {}
if data == nil then return nil end
if data ~= nil then
local old_line = u.trim(u.get_first_chunk(data, "[^" .. "" .. "]+"))
local new_line = u.trim(u.get_last_chunk(data, "[^" .. "" .. "]+"))
line_data.new_line = tonumber(new_line)
line_data.old_line = tonumber(old_line)
end
if line_data.new_line == nil and line_data.old_line == nil then return nil end
return line_data
end
M.get_review_buffer_range = function(file_name)
if M.bufnr == nil then return nil, "Delta reviewer must be initialized first" end
local lines = vim.api.nvim_buf_get_lines(M.bufnr, 0, -1, false)
local start = nil
local stop = nil
for i, line in ipairs(lines) do
if start ~= nil and stop ~= nil then return { start, stop } end
if M.starts_with_file_symbol(line) then
-- Check if the file name matches the node name
local delta_file_name = u.get_last_chunk(line)
if file_name == delta_file_name then
start = i
elseif start ~= nil then
stop = i
end
end
end
-- We've reached the end of the file, set "stop" in case we already found start
stop = #lines
if start ~= nil and stop ~= nil then return { start, stop } end
end
M.starts_with_file_symbol = function(line)
for _, substring in ipairs({
state.settings.review_pane.delta.added_file,
state.settings.review_pane.delta.removed_file,
state.settings.review_pane.delta.modified_file,
}) do
if string.sub(line, 1, string.len(substring)) == substring then
return true
end
end
return false
end
M.get_review_buffer_lines = function(review_buffer_range)
local lines = {}
for i = review_buffer_range[1], review_buffer_range[2], 1 do
local line_content = vim.api.nvim_buf_get_lines(M.bufnr, i - 1, i, false)[1]
if string.find(line_content, "") then
table.insert(lines, { line_content = line_content, line_number = i })
end
end
return lines
end
return M

View File

@@ -0,0 +1,36 @@
-- This Module will pick the reviewer set in the user's
-- settings and then map all of it's functions
local state = require("gitlab.state")
local delta = require("gitlab.reviewer.delta")
local M = {
reviewer = nil,
}
local reviewer_map = {
delta = delta
}
M.init = function()
local reviewer = reviewer_map[state.settings.reviewer]
if reviewer == nil then
vim.notify(string.format("gitlab.nvim could not find reviewer %s", state.settings.reviewer), vim.log.levels.ERROR)
return
end
M.open = reviewer.open
-- Opens the reviewer window
M.jump = reviewer.jump
-- Jumps to the location provided in the reviewer window
-- Parameters:
-- • {file_name} The name of the file to jump to
-- • {new_line} The new_line of the change
-- • {interval} The old_lien of the change
M.get_location = reviewer.get_location
-- Returns the current location (based on cursor) from the reviewer window
end
return M

69
lua/gitlab/server.lua Normal file
View File

@@ -0,0 +1,69 @@
-- This module contains the logic responsible for building and starting
-- the Golang server. The Go server is responsible for making API calls
-- to Gitlab and returning the data
local job = require("gitlab.job")
local state = require("gitlab.state")
local u = require("gitlab.utils")
local M = {}
-- Starts the Go server and call the callback provided
M.start_server = function(callback)
local command = state.settings.bin
.. " "
.. state.settings.project_id
.. " "
.. state.settings.gitlab_url
.. " "
.. state.settings.port
.. " "
.. state.settings.auth_token
.. " "
.. state.settings.log_path
vim.fn.jobstart(command, {
on_stdout = function(job_id)
if job_id <= 0 then
vim.notify("Could not start gitlab.nvim binary", vim.log.levels.ERROR)
else
callback()
end
end,
on_stderr = function(_, errors)
local err_msg = ''
for _, err in ipairs(errors) do
if err ~= "" and err ~= nil then
err_msg = err_msg .. err .. "\n"
end
end
if err_msg ~= '' then vim.notify(err_msg, vim.log.levels.ERROR) end
end
})
end
-- Builds the Go binary
M.build = function()
if not u.has_delta() then
vim.notify("Please install delta to use gitlab.nvim!", vim.log.levels.ERROR)
return
end
local file_path = u.current_file_path()
local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h:h")
state.settings.bin_path = parent_dir
state.settings.bin = parent_dir .. "/bin"
local binary_exists = vim.loop.fs_stat(state.settings.bin)
if binary_exists ~= nil then return end
local command = string.format("cd %s && make", state.settings.bin_path)
local installCode = os.execute(command .. "> /dev/null")
if installCode ~= 0 then
vim.notify("Could not install gitlab.nvim!", vim.log.levels.ERROR)
return false
end
return true
end
return M

View File

@@ -1,18 +1,37 @@
-- This module is responsible for holding and setting shared state between
-- modules, such as keybinding data and other settings and configuration.
-- This module is also responsible for ensuring that the state of the plugin
-- is valid via dependencies
local u = require("gitlab.utils")
local M = {}
-- These are the default keymaps for the plugin
M.keymaps = {
-- These are the default settings for the plugin
M.settings = {
port = 21036,
log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"),
popup = {
exit = "<Esc>",
perform_action = "<leader>s",
},
discussion_tree = {
jump_to_location = "o",
toggle = "<leader>d",
jump_to_file = "o",
edit_comment = "e",
delete_comment = "dd",
reply_to_comment = "r",
reply = "r",
toggle_node = "t",
toggle_resolved = "p"
toggle_resolved = "p",
relative = "editor",
position = "left",
size = "20%",
resolved = '',
unresolved = ''
},
review_pane = {
added_file = "",
modified_file = "",
removed_file = "",
},
dialogue = {
focus_next = { "j", "<Down>", "<Tab>" },
@@ -20,9 +39,84 @@ M.keymaps = {
close = { "<Esc>", "<C-c>" },
submit = { "<CR>", "<Space>" },
},
review = {
toggle = "<leader>glt"
}
go_server_running = false,
is_gitlab_project = false,
}
-- Merges user settings into the default settings, overriding them
M.merge_settings = function(args)
if args == nil then return end
M.settings = u.merge(M.settings, args)
end
M.print_settings = function()
u.P(M.settings)
end
-- Merges `.gitlab.nvim` settings into the state module
M.setPluginConfiguration = function()
local config_file_path = vim.fn.getcwd() .. "/.gitlab.nvim"
local config_file_content = u.read_file(config_file_path)
if config_file_content == nil then
return false
end
M.is_gitlab_project = true
local file = assert(io.open(config_file_path, "r"))
local properties = {}
for line in file:lines() do
for key, value in string.gmatch(line, "(.-)=(.-)$") do
properties[key] = value
end
end
M.settings.project_id = properties.project_id
M.settings.auth_token = properties.auth_token or os.getenv("GITLAB_TOKEN")
M.settings.gitlab_url = properties.gitlab_url or "https://gitlab.com"
if M.settings.auth_token == nil then
error("Missing authentication token for Gitlab")
end
if M.settings.project_id == nil then
error("Missing project ID in .gitlab.nvim file.")
end
if type(tonumber(M.settings.project_id)) ~= "number" then
error("The .gitlab.nvim project file's 'project_id' must be number")
end
return true
end
local function exit(popup)
popup:unmount()
end
-- These keymaps are buffer specific and are set dynamically when popups mount
M.set_popup_keymaps = function(popup, action)
vim.keymap.set('n', M.settings.popup.exit, function() exit(popup) end, { buffer = true })
if action ~= nil then
vim.keymap.set('n', M.settings.popup.perform_action, function()
local text = u.get_buffer_text(popup.bufnr)
exit(popup)
action(text)
end, { buffer = true })
end
end
-- Dependencies
-- These tables are passed to the async.sequence function, which calls them in sequence
-- before calling an action. They are used to set global state that's required
-- for each of the actions to occur. This is necessary because some Gitlab behaviors (like
-- adding a reviewer) requires some initial state.
M.dependencies = {
info = { endpoint = "/info", key = "info", state = "INFO" },
revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS" },
project_members = { endpoint = "/members", key = "ProjectMembers", state = "PROJECT_MEMBERS" }
}
return M

View File

@@ -1,29 +1,14 @@
local state = require("gitlab.state")
local M = {}
local function get_git_root()
local output = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null')
if vim.v.shell_error == 0 then
return vim.fn.substitute(output, '\n', '', '')
else
return nil
end
end
local function get_relative_file_path()
local git_root = get_git_root()
if git_root ~= nil then
local current_file = vim.fn.expand('%:p')
return vim.fn.substitute(current_file, git_root .. '/', '', '')
else
return nil
end
end
local get_current_line_number = function()
M.get_current_line_number = function()
return vim.api.nvim_call_function('line', { '.' })
end
function P(...)
M.has_delta = function()
return vim.fn.executable("delta") == 1
end
M.P = function(...)
local objects = {}
for i = 1, select("#", ...) do
local v = select(i, ...)
@@ -34,21 +19,21 @@ function P(...)
return ...
end
local function get_buffer_text(bufnr)
M.get_buffer_text = function(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local text = table.concat(lines, "\n")
return text
end
local string_starts = function(str, start)
M.string_starts = function(str, start)
return str:sub(1, #start) == start
end
local press_enter = function()
M.press_enter = function()
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<CR>", false, true, true), "n", false)
end
local format_date = function(date_string)
M.format_date = function(date_string)
local date_table = os.date("!*t")
local year, month, day, hour, min, sec = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
local date = os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec })
@@ -78,20 +63,11 @@ local format_date = function(date_string)
end
end
local add_comment_sign = function(line_number)
local bufnr = vim.api.nvim_get_current_buf()
vim.cmd("sign define piet text= texthl=Substitute")
vim.fn.sign_place(0, "piet", "piet", bufnr, { lnum = line_number })
end
local function jump_to_file(filename, line_number)
M.jump_to_file = function(filename, line_number)
if line_number == nil then line_number = 1 end
vim.api.nvim_command("wincmd l")
local bufnr = vim.fn.bufnr(filename)
if bufnr ~= -1 then
-- Buffer is already open, switch to it
vim.cmd("buffer " .. bufnr)
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
M.jump_to_buffer(bufnr, line_number)
return
end
@@ -100,43 +76,12 @@ local function jump_to_file(filename, line_number)
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
end
local function find_value_by_id(tbl, id)
for i = 1, #tbl do
if tbl[i].id == tonumber(id) then
return tbl[i]
end
end
return nil
M.jump_to_buffer = function(bufnr, line_number)
vim.cmd("buffer " .. bufnr)
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
end
vim.cmd("highlight Gray guifg=#888888")
local function darken_metadata(bufnr, regex)
local num_lines = vim.api.nvim_buf_line_count(bufnr)
for i = 0, num_lines - 1 do
local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1]
if string.match(line, regex) then
vim.api.nvim_buf_add_highlight(bufnr, -1, 'Gray', i, 0, -1)
end
end
end
local function print_success(_, line)
if line ~= nil and line ~= "" then
vim.notify(line, vim.log.levels.INFO)
end
end
local function print_error(_, line)
if line ~= nil and line ~= "" then
vim.notify(line, vim.log.levels.ERROR)
end
end
local function exit(popup)
popup:unmount()
end
local create_popup_state = function(title, width, height)
M.create_popup_state = function(title, width, height)
return {
buf_options = {
filetype = 'markdown'
@@ -158,13 +103,20 @@ local create_popup_state = function(title, width, height)
}
end
local M = {}
M.merge_tables = function(defaults, overrides)
M.merge = function(defaults, overrides)
local result = {}
for key, value in pairs(defaults) do
if type(value) == "table" then
result[key] = M.merge_tables(value, overrides[key] or {})
result[key] = M.merge(value, overrides[key] or {})
else
result[key] = overrides[key] or value
end
end
for key, value in pairs(overrides) do
if type(value) == "table" then
result[key] = M.merge(value, overrides[key] or {})
else
result[key] = overrides[key] or value
end
@@ -173,7 +125,23 @@ M.merge_tables = function(defaults, overrides)
return result
end
local read_file = function(file_path)
M.join = function(tbl, separator)
separator = separator or " "
local result = ""
for _, value in pairs(tbl) do
result = result .. tostring(value) .. separator
end
-- Remove the trailing separator
if separator ~= "" then
result = result:sub(1, - #separator - 1)
end
return result
end
M.read_file = function(file_path)
local file = io.open(file_path, "r")
if file == nil then
return nil
@@ -184,22 +152,13 @@ local read_file = function(file_path)
return file_contents
end
local split_diff_view_filename = function(filename)
local hash, path = filename:match("://%.git/(/?[0-9a-f]+)(/.*)$")
if hash and path then
path = path:gsub("%.git/", ""):gsub("^/", "")
hash = hash:gsub("^/", "")
end
return hash, path
end
local current_file_path = function()
M.current_file_path = function()
local path = debug.getinfo(1, 'S').source:sub(2)
return vim.fn.fnamemodify(path, ':p')
end
local random = math.random
local function uuid()
M.uuid = function()
local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
return string.gsub(template, '[xy]', function(c)
local v = (c == 'x') and random(0, 0xf) or random(8, 0xb)
@@ -207,11 +166,7 @@ local function uuid()
end)
end
local attach_uuid = function(str)
return { text = str, id = uuid() }
end
local join_tables = function(table1, table2)
M.join_tables = function(table1, table2)
for _, value in ipairs(table2) do
table.insert(table1, value)
end
@@ -219,7 +174,7 @@ local join_tables = function(table1, table2)
return table1
end
local contains = function(array, search_value)
M.contains = function(array, search_value)
for _, value in ipairs(array) do
if value == search_value then
return true
@@ -228,7 +183,7 @@ local contains = function(array, search_value)
return false
end
local extract = function(t, property)
M.extract = function(t, property)
local resultTable = {}
for _, value in ipairs(t) do
if value[property] then
@@ -238,7 +193,7 @@ local extract = function(t, property)
return resultTable
end
local remove_last_chunk = function(sentence)
M.remove_last_chunk = function(sentence)
local words = {}
for word in sentence:gmatch("%S+") do
table.insert(words, word)
@@ -248,27 +203,45 @@ local remove_last_chunk = function(sentence)
return sentence_without_last
end
M.remove_last_chunk = remove_last_chunk
M.extract = extract
M.contains = contains
M.attach_uuid = attach_uuid
M.join_tables = join_tables
M.get_relative_file_path = get_relative_file_path
M.get_current_line_number = get_current_line_number
M.get_buffer_text = get_buffer_text
M.press_enter = press_enter
M.string_starts = string_starts
M.format_date = format_date
M.add_comment_sign = add_comment_sign
M.jump_to_file = jump_to_file
M.find_value_by_id = find_value_by_id
M.darken_metadata = darken_metadata
M.print_success = print_success
M.print_error = print_error
M.create_popup_state = create_popup_state
M.exit = exit
M.read_file = read_file
M.split_diff_view_filename = split_diff_view_filename
M.current_file_path = current_file_path
M.P = P
M.get_first_chunk = function(sentence, divider)
local words = {}
for word in sentence:gmatch(divider or "%S+") do
table.insert(words, word)
end
return words[1]
end
M.get_last_chunk = function(sentence, divider)
local words = {}
for word in sentence:gmatch(divider or "%S+") do
table.insert(words, word)
end
return words[#words]
end
M.trim = function(s)
return s:gsub("^%s+", ""):gsub("%s+$", "")
end
M.get_line_content = function(bufnr, start)
local current_buffer = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(
bufnr ~= nil and bufnr or current_buffer,
start - 1,
start,
false)
for _, line in ipairs(lines) do
return line
end
end
M.get_win_from_buf = function(bufnr)
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.fn.winbufnr(win) == bufnr then
return win
end
end
end
return M

12
todo.md
View File

@@ -1,13 +1,5 @@
## Todo
- [x] Install for non-Lazy users
- [x] Fix comments on non-changed file lines
- [x] Delete, Edit Comments
- [ ] Fix the u.merge function to avoid overwriting settings
- [ ] Finish the Reply functionality
- [ ] Auto-Pick buffer when Cycling Through Comments
- [x] Clean up Camel Case to Snake Case
- [x] Common Popup State
- [x] Silence make command on build (don't rebuild every time)
- [x] Fix gitlab.nvim vs. gitlab namespacing issue
- [x] Allow customization of keybindings
- [ ] Add logging functionality for all API calls
- [ ] Delete should refresh the tree