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:
committed by
GitHub
parent
ed67a03f8f
commit
19468a3d2d
139
README.md
139
README.md
@@ -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 = {
|
||||
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
|
||||
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
|
||||
toggle_node = "t", -- Opens or closes 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
|
||||
},
|
||||
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>" },
|
||||
}
|
||||
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)
|
||||
},
|
||||
symbols = {
|
||||
discussion_tree = { -- The discussion tree that holds all comments
|
||||
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 = "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"
|
||||
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>" },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
13
lua/gitlab/actions/approvals.lua
Normal file
13
lua/gitlab/actions/approvals.lua
Normal 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
|
||||
@@ -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)
|
||||
64
lua/gitlab/actions/comment.lua
Normal file
64
lua/gitlab/actions/comment.lua
Normal 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
|
||||
475
lua/gitlab/actions/discussions.lua
Normal file
475
lua/gitlab/actions/discussions.lua
Normal 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
|
||||
@@ -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
67
lua/gitlab/async.lua
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
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%",
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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,
|
||||
-- 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,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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
|
||||
-- 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
|
||||
M.run_job = function(endpoint, method, body, callback)
|
||||
local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s/", state.PORT) .. endpoint }
|
||||
local Job = require("plenary.job")
|
||||
local M = {}
|
||||
|
||||
M.run_job = function(endpoint, method, body, callback)
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
202
lua/gitlab/reviewer/delta.lua
Normal file
202
lua/gitlab/reviewer/delta.lua
Normal 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
|
||||
36
lua/gitlab/reviewer/init.lua
Normal file
36
lua/gitlab/reviewer/init.lua
Normal 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
69
lua/gitlab/server.lua
Normal 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
|
||||
@@ -1,18 +1,37 @@
|
||||
local M = {}
|
||||
-- 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
|
||||
|
||||
-- These are the default keymaps for the plugin
|
||||
M.keymaps = {
|
||||
local u = require("gitlab.utils")
|
||||
local M = {}
|
||||
|
||||
-- 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
|
||||
|
||||
@@ -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
12
todo.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user