From 495e64c8bc841fce1f733c021aff6d1c22e89680 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:21:50 -0500 Subject: [PATCH] Release (#440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: Enable sorting discussions by original comment (#422) * Feat: Improve popup UX (#426) * Feat: Automatically update MR summary details (#427) * Feat: Show update progress in winbar (#432) * Feat: Abbreviate winbar (#439) * Fix: Note Creation Bug (#441) * Fix: Checking whether comment can be created (#434) * Fix: Syntax in discussion tree (#433) * fix: improve indication of resolved threads and drafts (#442) * Docs: Various minor improvements (#445) --------- Co-authored-by: Jakub F. Bortlík --- .github/CONTRIBUTING.md | 2 +- .github/workflows/go.yaml | 4 + .github/workflows/lua.yaml | 2 + README.md | 54 ++-- after/syntax/gitlab.vim | 45 ++-- cmd/app/list_discussions.go | 47 +++- cmd/app/list_discussions_test.go | 55 +++- doc/gitlab.nvim.txt | 54 ++-- lua/gitlab/actions/approvals.lua | 17 +- .../actions/assignees_and_reviewers.lua | 11 +- lua/gitlab/actions/comment.lua | 218 ++++++++-------- lua/gitlab/actions/common.lua | 2 +- lua/gitlab/actions/create_mr.lua | 36 +-- lua/gitlab/actions/discussions/init.lua | 64 +++-- lua/gitlab/actions/discussions/tree.lua | 13 +- lua/gitlab/actions/discussions/winbar.lua | 119 ++++++--- lua/gitlab/actions/draft_notes/init.lua | 1 + lua/gitlab/actions/help.lua | 24 +- lua/gitlab/actions/labels.lua | 11 +- lua/gitlab/actions/merge.lua | 9 +- lua/gitlab/actions/merge_requests.lua | 21 +- lua/gitlab/actions/miscellaneous.lua | 64 ----- lua/gitlab/actions/pipeline.lua | 6 +- lua/gitlab/actions/summary.lua | 84 ++++-- lua/gitlab/annotations.lua | 1 + lua/gitlab/colors.lua | 18 +- lua/gitlab/git.lua | 15 +- lua/gitlab/init.lua | 2 +- lua/gitlab/popup.lua | 239 ++++++++++++++++++ lua/gitlab/reviewer/init.lua | 34 ++- lua/gitlab/state.lua | 107 ++------ lua/gitlab/utils/init.lua | 71 +----- 32 files changed, 883 insertions(+), 567 deletions(-) create mode 100644 lua/gitlab/popup.lua diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 98d3c074..d220b816 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for taking time to contribute to this plugin! Please follow these step It's possible that the feature you want is already implemented, or does not belong in `gitlab.nvim` at all. By creating an issue first you can have a conversation with the maintainers about the functionality first. While this is not strictly necessary, it greatly increases the likelihood that your merge request will be accepted. -2. Fork the repository, and create a new feature branch for your desired functionality. Make your changes. +2. Fork the repository, and create a new feature branch off the `develop` branch for your desired functionality. Make your changes. If you are using Lazy as a plugin manager, the easiest way to work on changes is by setting a specific path for the plugin that points to your repository locally. This is what I do: diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 2786b74b..73f5ad13 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -4,6 +4,10 @@ on: branches: - main - develop + paths: + - 'cmd/**' # Ignore changes to the Lua code + - 'go.sum' + - 'go.mod' jobs: go_lint: name: Lint Go 💅 diff --git a/.github/workflows/lua.yaml b/.github/workflows/lua.yaml index 75042618..a8998468 100644 --- a/.github/workflows/lua.yaml +++ b/.github/workflows/lua.yaml @@ -4,6 +4,8 @@ on: branches: - main - develop + paths: + - 'lua/**' # Ignore changes to the Go code jobs: lua_lint: name: Lint Lua 💅 diff --git a/README.md b/README.md index 4660110d..1b6841af 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ This Neovim plugin is designed to make it easy to review Gitlab MRs from within - View and manage pipeline Jobs - Upload files, jump to the browser, and a lot more! -![Screenshot 2024-01-13 at 10 43 32 AM](https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/8dd8b961-a6b5-4e09-b87f-dc4a17b14149) -![Screenshot 2024-01-13 at 10 43 17 AM](https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/079842de-e8a4-45c5-98c2-dcafc799c904) +![Screenshot 2024-12-08 at 5 43 53 PM](https://github.com/user-attachments/assets/cb9e94e3-3817-4846-ba44-16ec06ea7654) https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dc5c07de-4ae6-4335-afe1-d554e3804372 @@ -36,16 +35,15 @@ For more detailed information about the Lua APIs please run `:h gitlab.nvim.api` With Lazy: ```lua -return { +{ "harrisoncramer/gitlab.nvim", dependencies = { "MunifTanjim/nui.nvim", "nvim-lua/plenary.nvim", "sindrets/diffview.nvim", "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. - "nvim-tree/nvim-web-devicons" -- Recommended but not required. Icons in discussion tree. + "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. }, - enabled = true, build = function () require("gitlab.server").build(true) end, -- Builds the Go binary config = function() require("gitlab").setup() @@ -53,30 +51,32 @@ return { } ``` -And with Packer: +And with pckr.nvim: ```lua - use { - "harrisoncramer/gitlab.nvim", - requires = { - "MunifTanjim/nui.nvim", - "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim" - "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. - "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. - }, - build = function() - require("gitlab.server").build() - end, - branch = "develop", - config = function() - require("diffview") -- We require some global state from diffview - local gitlab = require("gitlab") - gitlab.setup() - end, - } +{ + "harrisoncramer/gitlab.nvim", + requires = { + "MunifTanjim/nui.nvim", + "nvim-lua/plenary.nvim", + "sindrets/diffview.nvim", + "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. + "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. + }, + run = function() require("gitlab.server").build() end, -- Builds the Go binary + config = function() + require("diffview") -- We require some global state from diffview + require("gitlab").setup() + end, +} ``` +Add `branch = "develop",` to your configuration if you want to use the (possibly unstable) development version of `gitlab.nvim`. + +## Contributing + +Contributions to the plugin are welcome. Please read [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before you start working on a pull request. + ## Connecting to Gitlab This plugin requires an auth token to connect to Gitlab. The token can be set in the root directory of the project in a `.gitlab.nvim` environment file, or can be set via a shell environment variable called `GITLAB_TOKEN` instead. If both are present, the `.gitlab.nvim` file will take precedence. @@ -122,7 +122,3 @@ For a list of all these settings please run `:h gitlab.nvim.configuring-the-plug The plugin sets up a number of useful keybindings in the special buffers it creates, and some global keybindings as well. Refer to the relevant section of the manual `:h gitlab.nvim.keybindings` for more details. For more information about each of these commands, and about the APIs in general, run `:h gitlab.nvim.api` - -## Contributing - -Contributions to the plugin are welcome. Please read [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before you start working on a pull request. diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index 3acf898a..d19182c5 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -2,29 +2,26 @@ if filereadable($VIMRUNTIME . '/syntax/markdown.vim') source $VIMRUNTIME/syntax/markdown.vim endif -syntax match Date "\v\d+\s+\w+\s+ago" -highlight link Date GitlabDate - -execute 'syntax match Unresolved /\s' . g:gitlab_discussion_tree_unresolved . '\s\?/' -highlight link Unresolved GitlabUnresolved - -execute 'syntax match Resolved /\s' . g:gitlab_discussion_tree_resolved . '\s\?/' -highlight link Resolved GitlabResolved - -execute 'syntax match GitlabDiscussionOpen /^\s*' . g:gitlab_discussion_tree_expander_open . '/' -highlight link GitlabDiscussionOpen GitlabExpander - -execute 'syntax match GitlabDiscussionClosed /^\s*' . g:gitlab_discussion_tree_expander_closed . '/' -highlight link GitlabDiscussionClosed GitlabExpander - -execute 'syntax match Draft /' . g:gitlab_discussion_tree_draft . '/' -highlight link Draft GitlabDraft - -execute 'syntax match Username "@[a-zA-Z0-9.]\+"' -highlight link Username GitlabUsername - -execute 'syntax match Mention "\%(' . g:gitlab_discussion_tree_expander_open . '\|' - \ . g:gitlab_discussion_tree_expander_closed . '\)\@lua - return { + { "harrisoncramer/gitlab.nvim", dependencies = { "MunifTanjim/nui.nvim", "nvim-lua/plenary.nvim", "sindrets/diffview.nvim", "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. - "nvim-tree/nvim-web-devicons" -- Recommended but not required. Icons in discussion tree. + "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. }, - enabled = true, build = function () require("gitlab.server").build(true) end, -- Builds the Go binary config = function() require("gitlab").setup() end, } < -And with Packer: +And with pckr.nvim: >lua - use { + { "harrisoncramer/gitlab.nvim", requires = { "MunifTanjim/nui.nvim", @@ -86,14 +85,10 @@ And with Packer: "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. }, - build = function() - require("gitlab.server").build() - end, - branch = "develop", + run = function() require("gitlab.server").build() end, -- Builds the Go binary config = function() require("diffview") -- We require some global state from diffview - local gitlab = require("gitlab") - gitlab.setup() + require("gitlab").setup() end, } < @@ -200,7 +195,7 @@ you call this function with no values the defaults will be used: next_field = "", -- Cycle to the next field. Accepts |count|. prev_field = "", -- Cycle to the previous field. Accepts |count|. perform_action = "ZZ", -- Once in normal mode, does action (like saving comment or applying description edit, etc) - perform_linewise_action = "ZA", -- Once in normal mode, does the linewise action (see logs for this job, etc) + perform_linewise_action, = "ZA", -- Once in normal mode, does the linewise action (see logs for this job, etc) discard_changes = "ZQ", -- Quit the popup discarding changes, the popup content is not saved to the `temp_registers` (see `:h gitlab.nvim.temp-registers`) }, discussion_tree = { @@ -219,6 +214,7 @@ you call this function with no values the defaults will be used: toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" publish_draft = "P", -- Publish the currently focused note/comment toggle_draft_mode = "D", -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) + toggle_sort_method = "st", -- Toggle whether discussions are sorted by the "latest_reply", or by "original_comment", see `:h gitlab.nvim.toggle_sort_method` toggle_node = "t", -- Open or close the discussion toggle_all_discussions = "T", -- Open or close separately both resolved and unresolved discussions toggle_resolved_discussions = "R", -- Open or close all resolved discussions @@ -234,36 +230,44 @@ you call this function with no values the defaults will be used: }, }, popup = { -- The popup for comment creation, editing, and replying - width = "40%", - height = "60%", + width = "40%", -- Can be a percentage (string or decimal, "40%" = 0.4) of editor screen width, or an integer (number of columns) + height = "60%", -- Can be a percentage (string or decimal, "60%" = 0.6) of editor screen width, or an integer (number of rows) + position = "50%", -- Position (from the top left corner), either a number or percentage string that applies to both horizontal and vertical position, or a table that specifies them separately, e.g., { row = "90%", col = "100%" } places popups in the bottom right corner while leaving the status line visible border = "rounded", -- One of "rounded", "single", "double", "solid" opacity = 1.0, -- From 0.0 (fully transparent) to 1.0 (fully opaque) comment = nil, -- Individual popup overrides, e.g. { width = "60%", height = "80%", border = "single", opacity = 0.85 }, edit = nil, note = nil, - pipeline = nil, + help = nil, -- Width and height are calculated automatically and cannot be overridden + pipeline = nil, -- Width and height are calculated automatically and cannot be overridden reply = nil, squash_message = nil, + create_mr = { width = "95%", height = "95%" }, + summary = { width = "95%", height = "95%" }, temp_registers = {}, -- List of registers for backing up popup content (see `:h gitlab.nvim.temp-registers`) }, discussion_tree = { -- The discussion tree that holds all comments expanders = { -- Discussion tree icons expanded = " ", -- Icon for expanded discussion thread - collapsed = " ", -- Icon for collapsed discussion thread + collapsed = " ", -- Icon for collapsed discussion thread indentation = " ", -- Indentation Icon }, + spinner_chars = { "/", "|", "\\", "-" }, -- Characters for the refresh animation auto_open = true, -- Automatically open when the reviewer is opened - default_view = "discussions" -- Show "discussions" or "notes" by default + default_view = "discussions", -- Show "discussions" or "notes" by default blacklist = {}, -- List of usernames to remove from tree (bots, CI, etc) + sort_by = "latest_reply", -- Sort discussion tree by the "latest_reply", or by "original_comment", see `:h gitlab.nvim.toggle_sort_method` keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling - position = "left", -- "top", "right", "bottom" or "left" + position = "bottom", -- "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 + unlinked = "󰌸", -- Symbol to show next to unliked comments (i.e., not threads) + draft = "✎", -- Symbol to show next to draft comments/notes tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file draft_mode = false, -- Whether comments are posted as drafts as part of a review - winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) + winbar = nil, -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, emojis = { @@ -330,7 +334,7 @@ you call this function with no values the defaults will be used: squash = false, -- Whether the commits will be marked for squashing fork = { enabled = false, -- If making an MR from a fork - forked_project_id = nil -- The ID of the project you are merging into. If nil, will be prompted. + forked_project_id = nil, -- The ID of the project you are merging into. If nil, will be prompted. }, title_input = { -- Default settings for MR title input window width = 40, @@ -349,6 +353,9 @@ you call this function with no values the defaults will be used: resolved = "DiagnosticSignOk", unresolved = "DiagnosticSignWarn", draft = "DiffviewNonText", + draft_mode = "DiagnosticWarn", + live_mode = "DiagnosticOk", + sort_method = "Keyword", } } }) @@ -929,6 +936,13 @@ gitlab.toggle_draft_mode() ~ Toggles between draft mode, where comments and notes are added to a review as drafts, and regular (or live) mode, where comments are posted immediately. + *gitlab.nvim.toggle_sort_method* +gitlab.toggle_sort_method() ~ + +Toggles whether the discussion tree is sorted by the "latest_reply", with +threads with the most recent activity on top (the default), or by +"original_comment", with the oldest threads on top. + *gitlab.nvim.add_assignee* gitlab.add_assignee() ~ diff --git a/lua/gitlab/actions/approvals.lua b/lua/gitlab/actions/approvals.lua index 8ca7669c..12b3e09d 100644 --- a/lua/gitlab/actions/approvals.lua +++ b/lua/gitlab/actions/approvals.lua @@ -1,13 +1,26 @@ local job = require("gitlab.job") +local state = require("gitlab.state") +local u = require("gitlab.utils") local M = {} +local refresh_status_state = function(data) + u.notify(data.message, vim.log.levels.INFO) + state.load_new_state("info", function() + require("gitlab.actions.summary").update_summary_details() + end) +end + M.approve = function() - job.run_job("/mr/approve", "POST") + job.run_job("/mr/approve", "POST", nil, function(data) + refresh_status_state(data) + end) end M.revoke = function() - job.run_job("/mr/revoke", "POST") + job.run_job("/mr/revoke", "POST", nil, function(data) + refresh_status_state(data) + end) end return M diff --git a/lua/gitlab/actions/assignees_and_reviewers.lua b/lua/gitlab/actions/assignees_and_reviewers.lua index 329f3825..77716d1e 100644 --- a/lua/gitlab/actions/assignees_and_reviewers.lua +++ b/lua/gitlab/actions/assignees_and_reviewers.lua @@ -22,6 +22,12 @@ M.delete_reviewer = function() M.delete_popup("reviewer") end +local refresh_user_state = function(type, data, message) + u.notify(message, vim.log.levels.INFO) + state.INFO[type] = data + require("gitlab.actions.summary").update_summary_details() +end + M.add_popup = function(type) local plural = type .. "s" local current = state.INFO[plural] @@ -39,8 +45,7 @@ M.add_popup = function(type) table.insert(current_ids, choice.id) local body = { ids = current_ids } job.run_job("/mr/" .. type, "PUT", body, function(data) - u.notify(data.message, vim.log.levels.INFO) - state.INFO[plural] = data[plural] + refresh_user_state(plural, data[plural], data.message) end) end) end @@ -61,7 +66,7 @@ M.delete_popup = function(type) local body = { ids = ids } job.run_job("/mr/" .. type, "PUT", body, function(data) u.notify(data.message, vim.log.levels.INFO) - state.INFO[plural] = data[plural] + refresh_user_state(plural, data[plural], data.message) end) end) end diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index fbc3ad01..29fd08ca 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -3,10 +3,10 @@ --- to this module the data required to make the API calls local Popup = require("nui.popup") local Layout = require("nui.layout") -local diffview_lib = require("diffview.lib") local state = require("gitlab.state") local job = require("gitlab.job") local u = require("gitlab.utils") +local popup = require("gitlab.popup") local git = require("gitlab.git") local discussions = require("gitlab.actions.discussions") local draft_notes = require("gitlab.actions.draft_notes") @@ -46,6 +46,18 @@ local confirm_create_comment = function(text, visual_range, unlinked, discussion return end + -- Creating a draft reply, in response to a discussion ID + if discussion_id ~= nil and is_draft then + local body = { comment = text, discussion_id = discussion_id } + job.run_job("/mr/draft_notes/", "POST", body, function() + u.notify("Draft reply created!", vim.log.levels.INFO) + draft_notes.load_draft_notes(function() + discussions.rebuild_view(unlinked) + end) + end) + return + end + -- Creating a note (unlinked comment) if unlinked and discussion_id == nil then local body = { comment = text } @@ -89,18 +101,6 @@ local confirm_create_comment = function(text, visual_range, unlinked, discussion line_range = location_data.line_range, } - -- Creating a draft reply, in response to a discussion ID - if discussion_id ~= nil and is_draft then - local body = { comment = text, discussion_id = discussion_id, position = position_data } - job.run_job("/mr/draft_notes/", "POST", body, function() - u.notify("Draft reply created!", vim.log.levels.INFO) - draft_notes.load_draft_notes(function() - discussions.rebuild_view(unlinked) - end) - end) - return - end - -- Creating a new comment (linked to specific changes) local body = u.merge({ type = "text", comment = text }, position_data) local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" @@ -157,52 +157,26 @@ end ---multi-line comment. It also sets up the basic keybindings for switching between ---window panes, and for the non-primary sections. ---@param opts LayoutOpts ----@return NuiLayout|nil +---@return NuiLayout M.create_comment_layout = function(opts) - if opts.unlinked ~= true and opts.discussion_id == nil then - -- Check that diffview is initialized - if reviewer.tabnr == nil then - u.notify("Reviewer must be initialized first", vim.log.levels.ERROR) - return - end - - -- Check that Diffview is the current view - local view = diffview_lib.get_current_view() - if view == nil and not opts.reply then - u.notify("Comments should be left in the reviewer pane", vim.log.levels.ERROR) - return - end - - -- Check that we are in the diffview tab - local tabnr = vim.api.nvim_get_current_tabpage() - if tabnr ~= reviewer.tabnr then - u.notify("Line location can only be determined within reviewer window", vim.log.levels.ERROR) - return - end - - -- Check that the file has not been renamed - if reviewer.is_file_renamed() and not reviewer.does_file_have_changes() then - u.notify("Commenting on (unchanged) renamed or moved files is not supported", vim.log.levels.WARN) - return - end - - -- Check that we are hovering over the code - local filetype = vim.bo[0].filetype - if not opts.reply and (filetype == "DiffviewFiles" or filetype == "gitlab") then - u.notify( - "Comments can only be left on the code. To leave unlinked comments, use gitlab.create_note() instead", - vim.log.levels.ERROR - ) - return - end + local popup_settings = state.settings.popup + local title + local user_settings + if opts.discussion_id ~= nil then + title = "Reply" + user_settings = popup_settings.reply + elseif opts.unlinked then + title = "Note" + user_settings = popup_settings.note + else + title = "Comment" + user_settings = popup_settings.comment end - - local title = opts.discussion_id and "Reply" or "Comment" - local settings = opts.discussion_id ~= nil and state.settings.popup.reply or state.settings.popup.comment + local settings = u.merge(popup_settings, user_settings or {}) M.current_win = vim.api.nvim_get_current_win() - M.comment_popup = Popup(u.create_popup_state(title, settings)) - M.draft_popup = Popup(u.create_box_popup_state("Draft", false)) + M.comment_popup = Popup(popup.create_popup_state(title, settings)) + M.draft_popup = Popup(popup.create_box_popup_state("Draft", false, settings)) M.start_line, M.end_line = u.get_visual_selection_boundaries() local internal_layout = Layout.Box({ @@ -211,98 +185,69 @@ M.create_comment_layout = function(opts) }, { dir = "col" }) local layout = Layout({ - position = "50%", + position = settings.position, relative = "editor", size = { - width = "50%", - height = "55%", + width = settings.width, + height = settings.height, }, }, internal_layout) - miscellaneous.set_cycle_popups_keymaps({ M.comment_popup, M.draft_popup }) + popup.set_cycle_popups_keymaps({ M.comment_popup, M.draft_popup }) + popup.set_up_autocommands(M.comment_popup, layout, M.current_win) local range = opts.ranged and { start_line = M.start_line, end_line = M.end_line } or nil local unlinked = opts.unlinked or false ---Keybinding for focus on draft section - state.set_popup_keymaps(M.draft_popup, function() + popup.set_popup_keymaps(M.draft_popup, function() local text = u.get_buffer_text(M.comment_popup.bufnr) confirm_create_comment(text, range, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(M.current_win) - end, miscellaneous.toggle_bool, miscellaneous.non_editable_popup_opts) + end, miscellaneous.toggle_bool, popup.non_editable_popup_opts) ---Keybinding for focus on text section - state.set_popup_keymaps(M.comment_popup, function(text) + popup.set_popup_keymaps(M.comment_popup, function(text) confirm_create_comment(text, range, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(M.current_win) - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) + end, miscellaneous.attach_file, popup.editable_popup_opts) vim.schedule(function() local draft_mode = state.settings.discussion_tree.draft_mode vim.api.nvim_buf_set_lines(M.draft_popup.bufnr, 0, -1, false, { u.bool_to_string(draft_mode) }) end) - --Send back to previous window on close - vim.api.nvim_create_autocmd("BufHidden", { - buffer = M.draft_popup.bufnr, - callback = function() - vim.api.nvim_set_current_win(M.current_win) - end, - }) - return layout end --- 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() - local has_clean_tree, err = git.has_clean_tree() - if err ~= nil then - return - end - - local is_modified = vim.bo[0].modified - if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then - u.notify( - "Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.", - vim.log.levels.WARN - ) - return - end - - if not M.sha_exists() then + if not M.can_create_comment(false) then return end local layout = M.create_comment_layout({ ranged = false, unlinked = false }) - if layout ~= nil then - layout:mount() - end + layout:mount() end --- This function will open a multi-line comment popup in order to create a multi-line comment --- on the changed/updated line in the current MR M.create_multiline_comment = function() - if not u.check_visual_mode() then - return - end - if not M.sha_exists() then + if not M.can_create_comment(true) then + u.press_escape() return end local layout = M.create_comment_layout({ ranged = true, unlinked = false }) - if layout ~= nil then - layout:mount() - end + layout:mount() end --- This function will open a a popup to create a "note" (e.g. unlinked comment) --- on the changed/updated line in the current MR M.create_note = function() local layout = M.create_comment_layout({ ranged = false, unlinked = true }) - if layout ~= nil then - layout:mount() - end + layout:mount() end ---Given the current visually selected area of text, builds text to fill in the @@ -347,21 +292,16 @@ end --- on the changed/updated line in the current MR --- See: https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html M.create_comment_suggestion = function() - if not u.check_visual_mode() then - return - end - if not M.sha_exists() then + if not M.can_create_comment(true) then + u.press_escape() return end local suggestion_lines, range_length = build_suggestion() local layout = M.create_comment_layout({ ranged = range_length > 0, unlinked = false }) - if layout ~= nil then - layout:mount() - else - return -- Failure in creating the comment layout - end + layout:mount() + vim.schedule(function() if suggestion_lines then vim.api.nvim_buf_set_lines(M.comment_popup.bufnr, 0, -1, false, suggestion_lines) @@ -369,6 +309,68 @@ M.create_comment_suggestion = function() end) end +---Returns true if it's possible to create an Inline Comment +---@param must_be_visual boolean True if current mode must be visual +---@return boolean +M.can_create_comment = function(must_be_visual) + -- Check that diffview is initialized + if reviewer.tabnr == nil then + u.notify("Reviewer must be initialized first", vim.log.levels.ERROR) + return false + end + + -- Check that we are in the Diffview tab + local tabnr = vim.api.nvim_get_current_tabpage() + if tabnr ~= reviewer.tabnr then + u.notify("Comments can only be left in the reviewer pane", vim.log.levels.ERROR) + return false + end + + -- Check that we are hovering over the code + local filetype = vim.bo[0].filetype + if filetype == "DiffviewFiles" or filetype == "gitlab" then + u.notify( + "Comments can only be left on the code. To leave unlinked comments, use gitlab.create_note() instead", + vim.log.levels.ERROR + ) + return false + end + + -- Check that the file has not been renamed + if reviewer.is_file_renamed() and not reviewer.does_file_have_changes() then + u.notify("Commenting on (unchanged) renamed or moved files is not supported", vim.log.levels.ERROR) + return false + end + + -- Check that we are in a valid buffer + if not M.sha_exists() then + return false + end + + -- Check that there aren't saved modifications + local file = reviewer.get_current_file_path() + if file == nil then + return false + end + local has_changes, err = git.has_changes(file) + if err ~= nil then + return false + end + -- Check that there aren't unsaved modifications + local is_modified = vim.bo[0].modified + if state.settings.reviewer_settings.diffview.imply_local and (is_modified or has_changes) then + u.notify("Cannot leave comments on changed files, please stash or commit and push", vim.log.levels.ERROR) + return false + end + + -- Check we're in visual mode for code suggestions and multiline comments + if must_be_visual and not u.check_visual_mode() then + return false + end + + return true +end + ---Checks to see whether you are commenting on a valid buffer. The Diffview plugin names non-existent ---buffers as 'null' ---@return boolean diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index ace7bbe4..a6734d26 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -13,7 +13,7 @@ local M = {} ---@return string M.build_note_header = function(note) if note.note then - return "@" .. state.USER.username .. " " .. "" + return "@" .. state.USER.username .. " " .. state.settings.discussion_tree.draft end return "@" .. note.author.username .. " " .. u.time_since(note.created_at) end diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 2e366946..7e6f35d4 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -5,6 +5,7 @@ local Input = require("nui.input") local Popup = require("nui.popup") local job = require("gitlab.job") local u = require("gitlab.utils") +local popup = require("gitlab.popup") local git = require("gitlab.git") local state = require("gitlab.state") local common = require("gitlab.actions.common") @@ -277,13 +278,13 @@ M.open_confirmation_popup = function(mr) action_before_exit = true, } - state.set_popup_keymaps(description_popup, M.create_mr, miscellaneous.attach_file, popup_opts) - state.set_popup_keymaps(title_popup, M.create_mr, nil, popup_opts) - state.set_popup_keymaps(target_popup, M.create_mr, M.select_new_target, popup_opts) - state.set_popup_keymaps(delete_branch_popup, M.create_mr, miscellaneous.toggle_bool, popup_opts) - state.set_popup_keymaps(squash_popup, M.create_mr, miscellaneous.toggle_bool, popup_opts) - state.set_popup_keymaps(forked_project_id_popup, M.create_mr, nil, popup_opts) - miscellaneous.set_cycle_popups_keymaps(popups) + popup.set_popup_keymaps(description_popup, M.create_mr, miscellaneous.attach_file, popup_opts) + popup.set_popup_keymaps(title_popup, M.create_mr, nil, popup_opts) + popup.set_popup_keymaps(target_popup, M.create_mr, M.select_new_target, popup_opts) + popup.set_popup_keymaps(delete_branch_popup, M.create_mr, miscellaneous.toggle_bool, popup_opts) + popup.set_popup_keymaps(squash_popup, M.create_mr, miscellaneous.toggle_bool, popup_opts) + popup.set_popup_keymaps(forked_project_id_popup, M.create_mr, nil, popup_opts) + popup.set_cycle_popups_keymaps(popups) vim.api.nvim_set_current_buf(M.description_bufnr) end) @@ -328,19 +329,20 @@ M.create_mr = function() end M.create_layout = function() - local title_popup = Popup(u.create_box_popup_state("Title", false)) + local settings = u.merge(state.settings.popup, state.settings.popup.create_mr or {}) + local title_popup = Popup(popup.create_box_popup_state("Title", false, settings)) M.title_bufnr = title_popup.bufnr - local description_popup = Popup(u.create_box_popup_state("Description", true)) + local description_popup = Popup(popup.create_popup_state("Description", settings)) M.description_bufnr = description_popup.bufnr - local target_branch_popup = Popup(u.create_box_popup_state("Target branch", false)) + local target_branch_popup = Popup(popup.create_box_popup_state("Target branch", false, settings)) M.target_bufnr = target_branch_popup.bufnr local delete_title = vim.o.columns > 110 and "Delete source branch" or "Delete source" - local delete_branch_popup = Popup(u.create_box_popup_state(delete_title, false)) + local delete_branch_popup = Popup(popup.create_box_popup_state(delete_title, false, settings)) M.delete_branch_bufnr = delete_branch_popup.bufnr local squash_title = vim.o.columns > 110 and "Squash commits" or "Squash" - local squash_popup = Popup(u.create_box_popup_state(squash_title, false)) + local squash_popup = Popup(popup.create_box_popup_state(squash_title, false, settings)) M.squash_bufnr = squash_popup.bufnr - local forked_project_id_popup = Popup(u.create_box_popup_state("Forked Project ID", false)) + local forked_project_id_popup = Popup(popup.create_box_popup_state("Forked Project ID", false, settings)) M.forked_project_id_bufnr = forked_project_id_popup.bufnr local boxes = {} @@ -360,14 +362,16 @@ M.create_layout = function() }, { dir = "col" }) local layout = Layout({ - position = "50%", + position = settings.position, relative = "editor", size = { - width = "95%", - height = "95%", + width = settings.width, + height = settings.height, }, }, internal_layout) + popup.set_up_autocommands(description_popup, layout, vim.api.nvim_get_current_win()) + layout:mount() return layout, diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 40f03acd..788ca398 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -7,12 +7,12 @@ local Popup = require("nui.popup") local NuiTree = require("nui.tree") local job = require("gitlab.job") local u = require("gitlab.utils") +local popup = require("gitlab.popup") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") local common = require("gitlab.actions.common") local List = require("gitlab.utils.list") local tree_utils = require("gitlab.actions.discussions.tree") -local miscellaneous = require("gitlab.actions.miscellaneous") local discussions_tree = require("gitlab.actions.discussions.tree") local draft_notes = require("gitlab.actions.draft_notes") local diffview_lib = require("diffview.lib") @@ -48,13 +48,15 @@ M.rebuild_view = function(unlinked, all) else M.rebuild_discussion_tree() end - M.refresh_diagnostics_and_winbar() + state.discussion_tree.last_updated = os.time() + M.refresh_diagnostics() end) end ---Makes API call to get the discussion data, stores it in the state, and calls the callback ---@param callback function|nil M.load_discussions = function(callback) + state.discussion_tree.last_updated = nil state.load_new_state("discussion_data", function(data) if not state.DISCUSSION_DATA then state.DISCUSSION_DATA = {} @@ -70,9 +72,10 @@ end ---Initialize everything for discussions like setup of signs, callbacks for reviewer, etc. M.initialize_discussions = function() + state.discussion_tree.last_updated = os.time() signs.setup_signs() reviewer.set_callback_for_file_changed(function() - M.refresh_diagnostics_and_winbar() + M.refresh_diagnostics() M.modifiable(false) reviewer.set_reviewer_keymaps() end) @@ -102,11 +105,10 @@ M.modifiable = function(bool) end --- Take existing data and refresh the diagnostics, the winbar, and the signs -M.refresh_diagnostics_and_winbar = function() +M.refresh_diagnostics = function() if state.settings.discussion_signs.enabled then diagnostics.refresh_diagnostics() end - winbar.update_winbar() common.add_empty_titles() end @@ -154,7 +156,7 @@ M.open = function(callback) end vim.schedule(function() - M.refresh_diagnostics_and_winbar() + M.refresh_diagnostics() end) end @@ -251,9 +253,7 @@ M.reply = function(tree) reply = true, }) - if layout then - layout:mount() - end + layout:mount() end -- This function (settings.keymaps.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment @@ -284,7 +284,7 @@ end -- This function (settings.keymaps.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree M.edit_comment = function(tree, unlinked) - local edit_popup = Popup(u.create_popup_state("Edit Comment", state.settings.popup.edit)) + local edit_popup = Popup(popup.create_popup_state("Edit Comment", state.settings.popup.edit)) local current_node = tree:get_node() local note_node = common.get_note_node(tree, current_node) local root_node = common.get_root_node(tree, current_node) @@ -293,6 +293,8 @@ M.edit_comment = function(tree, unlinked) return end + popup.set_up_autocommands(edit_popup, nil, vim.api.nvim_get_current_win()) + edit_popup:mount() -- Gather all lines from immediate children that aren't note nodes @@ -310,19 +312,19 @@ M.edit_comment = function(tree, unlinked) -- Draft notes module handles edits for draft notes if M.is_draft_note(tree) then - state.set_popup_keymaps( + popup.set_popup_keymaps( edit_popup, draft_notes.confirm_edit_draft_note(note_node.id, unlinked), nil, - miscellaneous.editable_popup_opts + popup.editable_popup_opts ) else local comment = require("gitlab.actions.comment") - state.set_popup_keymaps( + popup.set_popup_keymaps( edit_popup, comment.confirm_edit_comment(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked), nil, - miscellaneous.editable_popup_opts + popup.editable_popup_opts ) end end @@ -585,7 +587,7 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) if keymaps.discussion_tree.jump_to_reviewer then vim.keymap.set("n", keymaps.discussion_tree.jump_to_reviewer, function() if M.is_current_node_note(tree) then - common.jump_to_reviewer(tree, M.refresh_diagnostics_and_winbar) + common.jump_to_reviewer(tree, M.refresh_diagnostics) end end, { buffer = bufnr, desc = "Jump to reviewer", nowait = keymaps.discussion_tree.jump_to_reviewer_nowait }) end @@ -603,7 +605,6 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) if keymaps.discussion_tree.refresh_data then vim.keymap.set("n", keymaps.discussion_tree.refresh_data, function() - u.notify("Refreshing data...", vim.log.levels.INFO) draft_notes.rebuild_view(unlinked, false) end, { buffer = bufnr, @@ -646,6 +647,16 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) }) end + if keymaps.discussion_tree.toggle_sort_method then + vim.keymap.set("n", keymaps.discussion_tree.toggle_sort_method, function() + M.toggle_sort_method() + end, { + buffer = bufnr, + desc = "Toggle sort method", + nowait = keymaps.discussion_tree.toggle_sort_method_nowait, + }) + end + if keymaps.discussion_tree.toggle_resolved then vim.keymap.set("n", keymaps.discussion_tree.toggle_resolved, function() if M.is_current_node_note(tree) and not M.is_draft_note(tree) then @@ -746,16 +757,6 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) }) end - if keymaps.discussion_tree.print_node then - vim.keymap.set("n", keymaps.discussion_tree.print_node, function() - common.print_node(tree) - end, { - buffer = bufnr, - desc = "Print current node (for debugging)", - nowait = keymaps.discussion_tree.print_node_nowait, - }) - end - if keymaps.discussion_tree.add_emoji then vim.keymap.set("n", keymaps.discussion_tree.add_emoji, function() M.add_emoji_to_note(tree, unlinked) @@ -792,7 +793,18 @@ end ---Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) M.toggle_draft_mode = function() state.settings.discussion_tree.draft_mode = not state.settings.discussion_tree.draft_mode +end + +---Toggle between sorting by "original comment" (oldest at the top) or "latest reply" (newest at the +---top). +M.toggle_sort_method = function() + if state.settings.discussion_tree.sort_by == "original_comment" then + state.settings.discussion_tree.sort_by = "latest_reply" + else + state.settings.discussion_tree.sort_by = "original_comment" + end winbar.update_winbar() + M.rebuild_view(false, true) end ---Indicates whether the node under the cursor is a draft note or not diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index a34f5112..7451a802 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -277,13 +277,16 @@ local function build_note_body(note, resolve_info) ) end - local resolve_symbol = "" + local symbol = "" + local is_draft = note.note ~= nil if resolve_info ~= nil and resolve_info.resolvable then - resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved + symbol = resolve_info.resolved and state.settings.discussion_tree.resolved or state.settings.discussion_tree.unresolved + elseif not is_draft and resolve_info and not resolve_info.resolvable then + symbol = state.settings.discussion_tree.unlinked end - local noteHeader = common.build_note_header(note) .. " " .. resolve_symbol + local noteHeader = common.build_note_header(note) .. " " .. symbol return noteHeader, text_nodes end @@ -454,7 +457,9 @@ M.restore_cursor_position = function(winid, tree, original_node, root_node) end end if line_number ~= nil then - vim.api.nvim_win_set_cursor(winid, { line_number, 0 }) + if vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_cursor(winid, { line_number, 0 }) + end end end diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 5c831846..83f41357 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -54,7 +54,19 @@ local get_data = function(nodes) return total_resolvable, total_resolved, total_non_resolvable end +local spinner_index = 0 +state.discussion_tree.last_updated = nil + local function content() + local updated + if state.discussion_tree.last_updated then + local last_update = tostring(os.date("!%Y-%m-%dT%H:%M:%S", state.discussion_tree.last_updated)) + updated = u.time_since(last_update) .. " ⟳" + else + spinner_index = (spinner_index % #state.settings.discussion_tree.spinner_chars) + 1 + updated = state.settings.discussion_tree.spinner_chars[spinner_index] + end + local resolvable_discussions, resolved_discussions, non_resolvable_discussions = get_data(state.DISCUSSION_DATA.discussions) local resolvable_notes, resolved_notes, non_resolvable_notes = get_data(state.DISCUSSION_DATA.unlinked_discussions) @@ -82,9 +94,10 @@ local function content() resolved_notes = resolved_notes, non_resolvable_notes = non_resolvable_notes, help_keymap = state.settings.keymaps.help, + updated = updated, } - return M.make_winbar(t) + return state.settings.discussion_tree.winbar and state.settings.discussion_tree.winbar(t) or M.make_winbar(t) end ---This function updates the winbar @@ -108,7 +121,7 @@ M.update_winbar = function() end local function get_connector(base_title) - return string.match(base_title, "%($") and "" or "; " + return string.match(base_title, "%($") and "" or " " end ---Builds the title string for both sections, using the count of resolvable and draft nodes @@ -116,84 +129,128 @@ end ---@param resolvable_count integer ---@param resolved_count integer ---@param drafts_count integer +---@param focused boolean ---@return string local add_drafts_and_resolvable = function( base_title, resolvable_count, resolved_count, drafts_count, - non_resolvable_count + non_resolvable_count, + focused ) if resolvable_count == 0 and drafts_count == 0 and non_resolvable_count == 0 then return base_title end - base_title = base_title .. " (" - if non_resolvable_count ~= 0 then - base_title = base_title .. u.pluralize(non_resolvable_count, "comment") - end if resolvable_count ~= 0 then - base_title = base_title - .. get_connector(base_title) - .. string.format("%d/%s", resolved_count, u.pluralize(resolvable_count, "thread")) + base_title = base_title .. M.get_resolved_text(focused, resolved_count, resolvable_count) + end + if non_resolvable_count ~= 0 then + base_title = base_title .. M.get_nonresolveable_text(base_title, non_resolvable_count, focused) end if drafts_count ~= 0 then - base_title = base_title .. get_connector(base_title) .. u.pluralize(drafts_count, "draft") + base_title = base_title .. M.get_drafts_text(base_title, drafts_count, focused) end - base_title = base_title .. ")" return base_title end ---@param t WinbarTable M.make_winbar = function(t) - local discussion_title = add_drafts_and_resolvable( - "Inline Comments", + local discussions_focused = M.current_view_type == "discussions" + local discussion_text = add_drafts_and_resolvable( + "Inline Comments:", t.resolvable_discussions, t.resolved_discussions, t.inline_draft_notes, - t.non_resolvable_discussions + t.non_resolvable_discussions, + discussions_focused ) - local notes_title = add_drafts_and_resolvable( - "Notes", + local notes_text = add_drafts_and_resolvable( + "Notes:", t.resolvable_notes, t.resolved_notes, t.unlinked_draft_notes, - t.non_resolvable_notes + t.non_resolvable_notes, + not discussions_focused ) -- Colorize the active tab - if M.current_view_type == "discussions" then - discussion_title = "%#Text#" .. discussion_title - notes_title = "%#Comment#" .. notes_title - elseif M.current_view_type == "notes" then - discussion_title = "%#Comment#" .. discussion_title - notes_title = "%#Text#" .. notes_title + if discussions_focused then + discussion_text = "%#Text#" .. discussion_text + notes_text = "%#Comment#" .. notes_text + else + discussion_text = "%#Comment#" .. discussion_text + notes_text = "%#Text#" .. notes_text end + local sort_method = M.get_sort_method() local mode = M.get_mode() -- Join everything together and return it local separator = "%#Comment#|" local end_section = "%=" + local updated = "%#Text#" .. t.updated local help = "%#Comment#Help: " .. (t.help_keymap and t.help_keymap:gsub(" ", "") .. " " or "unmapped") return string.format( - " %s %s %s %s %s %s %s", - discussion_title, + " %s %s %s %s %s %s %s %s %s %s %s", + discussion_text, separator, - notes_title, + notes_text, end_section, + updated, + separator, + sort_method, + separator, mode, separator, help ) end +---Returns a string for the winbar indicating the sort method +---@return string +M.get_sort_method = function() + local sort_method = state.settings.discussion_tree.sort_by == "original_comment" and "↓ by thread" or "↑ by reply" + return "%#GitlabSortMethod#" .. sort_method .. "%#Comment#" +end + +M.get_resolved_text = function(focused, resolved_count, resolvable_count) + local text = focused and ("%#GitlabResolved#" .. state.settings.discussion_tree.resolved .. "%#Text#") + or state.settings.discussion_tree.resolved + return " " .. string.format("%d%s/%d", resolved_count, text, resolvable_count) +end + +M.get_drafts_text = function(base_title, drafts_count, focused) + return get_connector(base_title) + .. string.format( + "%d%s", + drafts_count, + ( + focused and ("%#GitlabDraft#" .. state.settings.discussion_tree.draft .. "%#Text#") + or state.settings.discussion_tree.draft + ) + ) +end + +M.get_nonresolveable_text = function(base_title, non_resolvable_count, focused) + return get_connector(base_title) + .. string.format( + "%d%s", + non_resolvable_count, + ( + focused and ("%#GitlabUnlinked#" .. state.settings.discussion_tree.unlinked .. "%#Text#") + or state.settings.discussion_tree.unlinked + ) + ) +end + ---Returns a string for the winbar indicating the mode type, live or draft ---@return string M.get_mode = function() if state.settings.discussion_tree.draft_mode then - return "%#DiagnosticWarn#Draft Mode" + return "%#GitlabDraftMode#Draft" else - return "%#DiagnosticOK#Live Mode" + return "%#GitlabLiveMode#Live" end end @@ -215,4 +272,8 @@ M.switch_view_type = function(override) M.update_winbar() end +-- Set up a timer to update the winbar periodically +local timer = vim.uv.new_timer() +timer:start(0, 100, vim.schedule_wrap(M.update_winbar)) + return M diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index ea7b5b42..e23665e9 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -25,6 +25,7 @@ end ---Makes API call to get the discussion data, stores it in the state, and calls the callback ---@param callback function|nil M.load_draft_notes = function(callback) + state.discussion_tree.last_updated = nil state.load_new_state("draft_notes", function() if callback ~= nil then callback() diff --git a/lua/gitlab/actions/help.lua b/lua/gitlab/actions/help.lua index d76c7b5e..991a1623 100644 --- a/lua/gitlab/actions/help.lua +++ b/lua/gitlab/actions/help.lua @@ -1,6 +1,6 @@ local M = {} - local u = require("gitlab.utils") +local popup = require("gitlab.popup") local event = require("nui.utils.autocmd").event local state = require("gitlab.state") local List = require("gitlab.utils.list") @@ -16,15 +16,31 @@ M.open = function() end return agg end, {}) + + table.insert(help_content_lines, "") + table.insert( + help_content_lines, + string.format( + "%s = draft; %s = unlinked comment; %s = resolved", + state.settings.discussion_tree.draft, + state.settings.discussion_tree.unlinked, + state.settings.discussion_tree.resolved + ) + ) + local longest_line = u.get_longest_string(help_content_lines) - local help_popup = - Popup(u.create_popup_state("Help", state.settings.popup.help, longest_line + 3, #help_content_lines + 3, 60)) + local opts = { "Help", state.settings.popup.help, longest_line + 3, #help_content_lines, 70 } + local help_popup = Popup(popup.create_popup_state(unpack(opts))) + help_popup:on(event.BufLeave, function() help_popup:unmount() end) + + popup.set_up_autocommands(help_popup, nil, vim.api.nvim_get_current_win(), opts) + help_popup:mount() - state.set_popup_keymaps(help_popup, "Help", nil) + popup.set_popup_keymaps(help_popup, "Help", nil) local currentBuffer = vim.api.nvim_get_current_buf() vim.api.nvim_buf_set_lines(currentBuffer, 0, #help_content_lines, false, help_content_lines) u.switch_can_edit_buf(currentBuffer, false) diff --git a/lua/gitlab/actions/labels.lua b/lua/gitlab/actions/labels.lua index da9447f6..e182df73 100644 --- a/lua/gitlab/actions/labels.lua +++ b/lua/gitlab/actions/labels.lua @@ -14,8 +14,10 @@ M.delete_label = function() M.delete_popup("label") end -local refresh_label_state = function(labels) +local refresh_label_state = function(labels, message) + u.notify(message, vim.log.levels.INFO) state.INFO.labels = labels + require("gitlab.actions.summary").update_summary_details() end local get_current_labels = function() @@ -41,9 +43,7 @@ M.add_popup = function(type) table.insert(current_labels, choice) local body = { labels = current_labels } job.run_job("/mr/" .. type, "PUT", body, function(data) - u.notify(data.message, vim.log.levels.INFO) - - refresh_label_state(data.labels) + refresh_label_state(data.labels, data.message) end) end) end @@ -59,8 +59,7 @@ M.delete_popup = function(type) local filtered_labels = u.filter(current_labels, choice) local body = { labels = filtered_labels } job.run_job("/mr/" .. type, "PUT", body, function(data) - u.notify(data.message, vim.log.levels.INFO) - refresh_label_state(data.labels) + refresh_label_state(data.labels, data.message) end) end) end diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua index 81d23846..81eed3f6 100644 --- a/lua/gitlab/actions/merge.lua +++ b/lua/gitlab/actions/merge.lua @@ -1,14 +1,14 @@ local u = require("gitlab.utils") +local popup = require("gitlab.popup") local Popup = require("nui.popup") local state = require("gitlab.state") local job = require("gitlab.job") local reviewer = require("gitlab.reviewer") -local miscellaneous = require("gitlab.actions.miscellaneous") local M = {} local function create_squash_message_popup() - return Popup(u.create_popup_state("Squash Commit Message", state.settings.popup.squash_message)) + return Popup(popup.create_popup_state("Squash Commit Message", state.settings.popup.squash_message)) end ---@class MergeOpts @@ -31,10 +31,11 @@ M.merge = function(opts) if merge_body.squash then local squash_message_popup = create_squash_message_popup() + popup.set_up_autocommands(squash_message_popup, nil, vim.api.nvim_get_current_win()) squash_message_popup:mount() - state.set_popup_keymaps(squash_message_popup, function(text) + popup.set_popup_keymaps(squash_message_popup, function(text) M.confirm_merge(merge_body, text) - end, nil, miscellaneous.editable_popup_opts) + end, nil, popup.editable_popup_opts) else M.confirm_merge(merge_body) end diff --git a/lua/gitlab/actions/merge_requests.lua b/lua/gitlab/actions/merge_requests.lua index 0af9db0c..d9af5e29 100644 --- a/lua/gitlab/actions/merge_requests.lua +++ b/lua/gitlab/actions/merge_requests.lua @@ -12,14 +12,6 @@ local M = {} ---Opens up a select menu that lets you choose a different merge request. ---@param opts ChooseMergeRequestOptions|nil M.choose_merge_request = function(opts) - local has_clean_tree, clean_tree_err = git.has_clean_tree() - if clean_tree_err ~= nil then - return - elseif has_clean_tree ~= "" then - u.notify("Your local branch has changes, please stash or commit and push", vim.log.levels.ERROR) - return - end - if opts == nil then opts = state.settings.choose_merge_request end @@ -38,6 +30,19 @@ M.choose_merge_request = function(opts) reviewer.close() end + if choice.source_branch ~= git.get_current_branch() then + local has_clean_tree, clean_tree_err = git.has_clean_tree() + if clean_tree_err ~= nil then + return + elseif not has_clean_tree then + u.notify( + "Cannot switch branch when working tree has changes, please stash or commit and push", + vim.log.levels.ERROR + ) + return + end + end + vim.schedule(function() local _, branch_switch_err = git.switch_branch(choice.source_branch) if branch_switch_err ~= nil then diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index df080665..368c198b 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -34,70 +34,6 @@ M.attach_file = function() end) end -M.editable_popup_opts = { - action_before_close = true, - action_before_exit = false, - save_to_temp_register = true, -} - -M.non_editable_popup_opts = { - action_before_close = true, - action_before_exit = false, - save_to_temp_register = false, -} - --- Get the index of the next popup when cycling forward -local function next_index(i, n, count) - count = count > 0 and count or 1 - for _ = 1, count do - if i < n then - i = i + 1 - elseif i == n then - i = 1 - end - end - return i -end - ----Get the index of the previous popup when cycling backward ----@param i integer The current index ----@param n integer The total number of popups ----@param count integer The count used with the keymap (replaced with 1 if no count was given) -local function prev_index(i, n, count) - count = count > 0 and count or 1 - for _ = 1, count do - if i > 1 then - i = i - 1 - elseif i == 1 then - i = n - end - end - return i -end - ----Setup keymaps for cycling popups. The keymap accepts count. ----@param popups table Table of Popups -M.set_cycle_popups_keymaps = function(popups) - local keymaps = require("gitlab.state").settings.keymaps - if keymaps.disable_all or keymaps.popup.disable_all then - return - end - - local number_of_popups = #popups - for i, popup in ipairs(popups) do - if keymaps.popup.next_field then - popup:map("n", keymaps.popup.next_field, function() - vim.api.nvim_set_current_win(popups[next_index(i, number_of_popups, vim.v.count)].winid) - end, { desc = "Go to next field (accepts count)", nowait = keymaps.popup.next_field_nowait }) - end - if keymaps.popup.prev_field then - popup:map("n", keymaps.popup.prev_field, function() - vim.api.nvim_set_current_win(popups[prev_index(i, number_of_popups, vim.v.count)].winid) - end, { desc = "Go to previous field (accepts count)", nowait = keymaps.popup.prev_field_nowait }) - end - end -end - ---Toggle the value in a "Boolean buffer" M.toggle_bool = function() local bufnr = vim.api.nvim_get_current_buf() diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua index bddc1949..00dc5c29 100644 --- a/lua/gitlab/actions/pipeline.lua +++ b/lua/gitlab/actions/pipeline.lua @@ -5,6 +5,7 @@ local Popup = require("nui.popup") local state = require("gitlab.state") local job = require("gitlab.job") local u = require("gitlab.utils") +local popup = require("gitlab.popup") local M = { pipeline_jobs = nil, latest_pipeline = nil, @@ -40,7 +41,8 @@ M.open = function() local height = 6 + #M.pipeline_jobs + 3 local pipeline_popup = - Popup(u.create_popup_state("Loading Pipeline...", state.settings.popup.pipeline, width, height, 60)) + Popup(popup.create_popup_state("Loading Pipeline...", state.settings.popup.pipeline, width, height, 60)) + popup.set_up_autocommands(pipeline_popup, nil, vim.api.nvim_get_current_win()) M.pipeline_popup = pipeline_popup pipeline_popup:mount() @@ -91,7 +93,7 @@ M.open = function() end pipeline_popup.border:set_text("top", "Pipeline Status", "center") - state.set_popup_keymaps(pipeline_popup, M.retrigger, M.see_logs) + popup.set_popup_keymaps(pipeline_popup, M.retrigger, M.see_logs) u.switch_can_edit_buf(bufnr, false) end) end diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 6d034be1..f23d0d8d 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -7,6 +7,7 @@ local git = require("gitlab.git") local job = require("gitlab.job") local common = require("gitlab.actions.common") local u = require("gitlab.utils") +local popup = require("gitlab.popup") local List = require("gitlab.utils.list") local state = require("gitlab.state") local miscellaneous = require("gitlab.actions.miscellaneous") @@ -34,6 +35,9 @@ M.summary = function() local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" } local layout, title_popup, description_popup, info_popup = M.create_layout(info_lines) + + layout:mount() + local popups = { title_popup, description_popup, @@ -41,6 +45,9 @@ M.summary = function() } M.layout = layout + M.info_popup = info_popup + M.title_popup = title_popup + M.description_popup = description_popup M.layout_buf = layout.bufnr M.layout_visible = true @@ -54,30 +61,28 @@ M.summary = function() vim.api.nvim_buf_set_lines(title_popup.bufnr, 0, -1, false, { title }) if info_popup then - vim.api.nvim_buf_set_lines(info_popup.bufnr, 0, -1, false, info_lines) - u.switch_can_edit_buf(info_popup.bufnr, false) - M.color_details(info_popup.bufnr) -- Color values in details popup + M.update_details_popup(info_popup.bufnr, info_lines) end - state.set_popup_keymaps( + popup.set_popup_keymaps( description_popup, M.edit_summary, miscellaneous.attach_file, { cb = exit, action_before_close = true, action_before_exit = true, save_to_temp_register = true } ) - state.set_popup_keymaps( + popup.set_popup_keymaps( title_popup, M.edit_summary, nil, { cb = exit, action_before_close = true, action_before_exit = true } ) - state.set_popup_keymaps( + popup.set_popup_keymaps( info_popup, M.edit_summary, nil, { cb = exit, action_before_close = true, action_before_exit = true } ) - miscellaneous.set_cycle_popups_keymaps(popups) + popup.set_cycle_popups_keymaps(popups) vim.api.nvim_set_current_buf(description_popup.bufnr) end) @@ -86,6 +91,23 @@ M.summary = function() git.check_mr_in_good_condition() end +M.update_summary_details = function() + if not M.info_popup or not M.info_popup.bufnr then + return + end + local details_lines = state.settings.info.enabled and M.build_info_lines() or { "" } + local internal_layout = M.create_internal_layout(details_lines, M.title_popup, M.description_popup, M.info_popup) + M.layout:update(M.get_outer_layout_config(), internal_layout) + M.update_details_popup(M.info_popup.bufnr, details_lines) +end + +M.update_details_popup = function(bufnr, info_lines) + u.switch_can_edit_buf(bufnr, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, info_lines) + u.switch_can_edit_buf(bufnr, false) + M.color_details(bufnr) -- Color values in details popup +end + -- Builds a lua list of strings that contain metadata about the current MR. Only builds the -- lines that users include in their state.settings.info.fields list. M.build_info_lines = function() @@ -165,16 +187,37 @@ M.edit_summary = function() end) end +---Create the Summary layout and individual popups that make up the Layout. +---@return NuiLayout, NuiPopup, NuiPopup, NuiPopup M.create_layout = function(info_lines) - local title_popup = Popup(u.create_box_popup_state(nil, false)) + local settings = u.merge(state.settings.popup, state.settings.popup.summary or {}) + local title_popup = Popup(popup.create_box_popup_state(nil, false, settings)) M.title_bufnr = title_popup.bufnr - local description_popup = Popup(u.create_box_popup_state("Description", true)) + local description_popup = Popup(popup.create_popup_state("Description", settings)) M.description_bufnr = description_popup.bufnr local details_popup + if state.settings.info.enabled then + details_popup = Popup(popup.create_box_popup_state("Details", false, settings)) + end + local internal_layout = M.create_internal_layout(info_lines, title_popup, description_popup, details_popup) + + local layout = Layout(M.get_outer_layout_config(), internal_layout) + + popup.set_up_autocommands(description_popup, layout, vim.api.nvim_get_current_win()) + + return layout, title_popup, description_popup, details_popup +end + +---Create the internal layout of the Summary and individual popups that make up the Layout. +---@param info_lines string[] Table of strings that make up the details content +---@param title_popup NuiPopup +---@param description_popup NuiPopup +---@param details_popup NuiPopup +---@return NuiLayout.Box +M.create_internal_layout = function(info_lines, title_popup, description_popup, details_popup) local internal_layout if state.settings.info.enabled then - details_popup = Popup(u.create_box_popup_state("Details", false)) if state.settings.info.horizontal then local longest_line = u.get_longest_string(info_lines) internal_layout = Layout.Box({ @@ -182,7 +225,7 @@ M.create_layout = function(info_lines) Layout.Box({ Layout.Box(details_popup, { size = longest_line + 3 }), Layout.Box(description_popup, { grow = 1 }), - }, { dir = "row", size = "100%" }), + }, { dir = "row", size = "95%" }), }, { dir = "col" }) else internal_layout = Layout.Box({ @@ -197,18 +240,21 @@ M.create_layout = function(info_lines) Layout.Box(description_popup, { grow = 1 }), }, { dir = "col" }) end + return internal_layout +end - local layout = Layout({ - position = "50%", +---Create the config for the outer Layout of the Summary +---@return nui_layout_options +M.get_outer_layout_config = function() + local settings = u.merge(state.settings.popup, state.settings.popup.summary or {}) + return { + position = settings.position, relative = "editor", size = { - width = "95%", - height = "95%", + width = settings.width, + height = settings.height, }, - }, internal_layout) - - layout:mount() - return layout, title_popup, description_popup, details_popup + } end M.color_details = function(bufnr) diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 9b1ae923..b1ade3c8 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -92,6 +92,7 @@ ---@field resolved_notes number ---@field non_resolvable_notes number ---@field help_keymap string +---@field updated string --- ---@class SignTable ---@field name string diff --git a/lua/gitlab/colors.lua b/lua/gitlab/colors.lua index a5b92f10..54d557a5 100644 --- a/lua/gitlab/colors.lua +++ b/lua/gitlab/colors.lua @@ -3,12 +3,13 @@ local state = require("gitlab.state") local colors = state.settings.colors -- Set icons into global vim variables for syntax matching -local expanders = state.settings.discussion_tree.expanders -vim.g.gitlab_discussion_tree_expander_open = expanders.expanded -vim.g.gitlab_discussion_tree_expander_closed = expanders.collapsed -vim.g.gitlab_discussion_tree_draft = "" -vim.g.gitlab_discussion_tree_resolved = "✓" -vim.g.gitlab_discussion_tree_unresolved = "-" +local discussion_tree = state.settings.discussion_tree +vim.g.gitlab_discussion_tree_expander_open = discussion_tree.expanders.expanded +vim.g.gitlab_discussion_tree_expander_closed = discussion_tree.expanders.collapsed +vim.g.gitlab_discussion_tree_draft = discussion_tree.draft +vim.g.gitlab_discussion_tree_resolved = discussion_tree.resolved +vim.g.gitlab_discussion_tree_unresolved = discussion_tree.unresolved +vim.g.gitlab_discussion_tree_unlinked = discussion_tree.unlinked local discussion = colors.discussion_tree @@ -17,7 +18,6 @@ local function get_colors_for_group(group) local normal_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(group)), "bg") return { fg = normal_fg, bg = normal_bg } end - vim.api.nvim_create_autocmd("VimEnter", { callback = function() vim.api.nvim_set_hl(0, "GitlabUsername", get_colors_for_group(discussion.username)) @@ -29,6 +29,10 @@ vim.api.nvim_create_autocmd("VimEnter", { vim.api.nvim_set_hl(0, "GitlabFileName", get_colors_for_group(discussion.file_name)) vim.api.nvim_set_hl(0, "GitlabResolved", get_colors_for_group(discussion.resolved)) vim.api.nvim_set_hl(0, "GitlabUnresolved", get_colors_for_group(discussion.unresolved)) + vim.api.nvim_set_hl(0, "GitlabUnlinked", get_colors_for_group(discussion.unlinked)) vim.api.nvim_set_hl(0, "GitlabDraft", get_colors_for_group(discussion.draft)) + vim.api.nvim_set_hl(0, "GitlabDraftMode", get_colors_for_group(discussion.draft_mode)) + vim.api.nvim_set_hl(0, "GitlabLiveMode", get_colors_for_group(discussion.live_mode)) + vim.api.nvim_set_hl(0, "GitlabSortMethod", get_colors_for_group(discussion.sort_method)) end, }) diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index aa74abc0..a633c86d 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -25,10 +25,19 @@ M.branches = function(args) return run_system(u.combine({ "git", "branch" }, args or {})) end ----Checks whether the tree has any changes that haven't been pushed to the remote ----@return string|nil, string|nil +---Returns true if the working tree hasn't got any changes that haven't been commited +---@return boolean, string|nil M.has_clean_tree = function() - return run_system({ "git", "status", "--short", "--untracked-files=no" }) + local changes, err = run_system({ "git", "status", "--short", "--untracked-files=no" }) + return changes == "", err +end + +---Returns true if the `file` has got any uncommitted changes +---@param file string File to check for changes +---@return boolean, string|nil +M.has_changes = function(file) + local changes, err = run_system({ "git", "status", "--short", "--untracked-files=no", "--", file }) + return changes ~= "", err end ---Gets the base directory of the current project diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 25378a74..f17c6320 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -92,10 +92,10 @@ return { end end, toggle_draft_mode = discussions.toggle_draft_mode, + toggle_sort_method = discussions.toggle_sort_method, publish_all_drafts = draft_notes.publish_all_drafts, refresh_data = function() -- This also rebuilds the regular views - u.notify("Refreshing data...", vim.log.levels.INFO) draft_notes.rebuild_view(false, true) end, -- Other functions 🤷 diff --git a/lua/gitlab/popup.lua b/lua/gitlab/popup.lua new file mode 100644 index 00000000..8ab0b513 --- /dev/null +++ b/lua/gitlab/popup.lua @@ -0,0 +1,239 @@ +local u = require("gitlab.utils") + +local M = {} + +---Get the popup view_opts +---@param title string The string to appear on top of the popup +---@param user_settings table|nil User-defined popup settings +---@param width number? Override default width +---@param height number? Override default height +---@param zindex number? Override default zindex +---@return table +M.create_popup_state = function(title, user_settings, width, height, zindex) + local settings = u.merge(require("gitlab.state").settings.popup, user_settings or {}) + local view_opts = { + buf_options = { + filetype = "markdown", + }, + relative = "editor", + enter = true, + focusable = true, + zindex = zindex or 50, + border = { + style = settings.border, + text = { + top = title, + }, + }, + position = settings.position, + size = { + width = width and math.min(width, vim.o.columns - 2) or settings.width, + height = height and math.min(height, vim.o.lines - 3) or settings.height, + }, + opacity = settings.opacity, + } + + return view_opts +end + +---Create view_opts for Box popups used inside popup Layouts +---@param title string|nil The string to appear on top of the popup +---@param enter boolean Whether the pop should be focused after creation +---@param settings table User defined popup settings +---@return table +M.create_box_popup_state = function(title, enter, settings) + return { + buf_options = { + filetype = "markdown", + }, + enter = enter or false, + focusable = true, + border = { + style = settings.border, + text = { + top = title, + }, + }, + opacity = settings.opacity, + } +end + +local function exit(popup, opts) + if opts.action_before_exit and opts.cb ~= nil then + opts.cb() + popup:unmount() + else + popup:unmount() + if opts.cb ~= nil then + opts.cb() + end + end +end + +-- These keymaps are buffer specific and are set dynamically when popups mount +M.set_popup_keymaps = function(popup, action, linewise_action, opts) + local settings = require("gitlab.state").settings + if settings.keymaps.disable_all or settings.keymaps.popup.disable_all then + return + end + + if opts == nil then + opts = {} + end + if action ~= "Help" and settings.keymaps.help then -- Don't show help on the help popup + vim.keymap.set("n", settings.keymaps.help, function() + local help = require("gitlab.actions.help") + help.open() + end, { buffer = popup.bufnr, desc = "Open help", nowait = settings.keymaps.help_nowait }) + end + if action ~= nil and settings.keymaps.popup.perform_action then + vim.keymap.set("n", settings.keymaps.popup.perform_action, function() + local text = u.get_buffer_text(popup.bufnr) + if opts.action_before_close then + action(text, popup.bufnr) + exit(popup, opts) + else + exit(popup, opts) + action(text, popup.bufnr) + end + end, { buffer = popup.bufnr, desc = "Perform action", nowait = settings.keymaps.popup.perform_action_nowait }) + end + + if linewise_action ~= nil and settings.keymaps.popup.perform_action then + vim.keymap.set("n", settings.keymaps.popup.perform_linewise_action, function() + local bufnr = vim.api.nvim_get_current_buf() + local linnr = vim.api.nvim_win_get_cursor(0)[1] + local text = u.get_line_content(bufnr, linnr) + linewise_action(text) + end, { + buffer = popup.bufnr, + desc = "Perform linewise action", + nowait = settings.keymaps.popup.perform_linewise_action_nowait, + }) + end + + if settings.keymaps.popup.discard_changes then + vim.keymap.set("n", settings.keymaps.popup.discard_changes, function() + local temp_registers = settings.popup.temp_registers + settings.popup.temp_registers = {} + vim.cmd("quit!") + settings.popup.temp_registers = temp_registers + end, { + buffer = popup.bufnr, + desc = "Quit discarding changes", + nowait = settings.keymaps.popup.discard_changes_nowait, + }) + end + + if opts.save_to_temp_register then + vim.api.nvim_create_autocmd("BufWinLeave", { + buffer = popup.bufnr, + callback = function() + local text = u.get_buffer_text(popup.bufnr) + for _, register in ipairs(settings.popup.temp_registers) do + vim.fn.setreg(register, text) + end + end, + }) + end + + if opts.action_before_exit then + vim.api.nvim_create_autocmd("BufWinLeave", { + buffer = popup.bufnr, + callback = function() + exit(popup, opts) + end, + }) + end +end + +--- Setup autocommands for the popup +--- @param popup NuiPopup +--- @param layout NuiLayout|nil +--- @param previous_window number|nil Number of window active before the popup was opened +--- @param opts table|nil Table with options for updating the popup +M.set_up_autocommands = function(popup, layout, previous_window, opts) + -- Make the popup/layout resizable + popup:on("VimResized", function() + if layout ~= nil then + layout:update() + else + popup:update_layout(opts and M.create_popup_state(unpack(opts))) + end + end) + + -- After closing the popup, refocus the previously active window + if previous_window ~= nil then + popup:on("BufHidden", function() + vim.schedule(function() + vim.api.nvim_set_current_win(previous_window) + end) + end) + end +end + +M.editable_popup_opts = { + action_before_close = true, + action_before_exit = false, + save_to_temp_register = true, +} + +M.non_editable_popup_opts = { + action_before_close = true, + action_before_exit = false, + save_to_temp_register = false, +} + +-- Get the index of the next popup when cycling forward +local function next_index(i, n, count) + count = count > 0 and count or 1 + for _ = 1, count do + if i < n then + i = i + 1 + elseif i == n then + i = 1 + end + end + return i +end + +---Get the index of the previous popup when cycling backward +---@param i integer The current index +---@param n integer The total number of popups +---@param count integer The count used with the keymap (replaced with 1 if no count was given) +local function prev_index(i, n, count) + count = count > 0 and count or 1 + for _ = 1, count do + if i > 1 then + i = i - 1 + elseif i == 1 then + i = n + end + end + return i +end + +---Setup keymaps for cycling popups. The keymap accepts count. +---@param popups table Table of Popups +M.set_cycle_popups_keymaps = function(popups) + local keymaps = require("gitlab.state").settings.keymaps + if keymaps.disable_all or keymaps.popup.disable_all then + return + end + + local number_of_popups = #popups + for i, popup in ipairs(popups) do + if keymaps.popup.next_field then + popup:map("n", keymaps.popup.next_field, function() + vim.api.nvim_set_current_win(popups[next_index(i, number_of_popups, vim.v.count)].winid) + end, { desc = "Go to next field (accepts count)", nowait = keymaps.popup.next_field_nowait }) + end + if keymaps.popup.prev_field then + popup:map("n", keymaps.popup.prev_field, function() + vim.api.nvim_set_current_win(popups[prev_index(i, number_of_popups, vim.v.count)].winid) + end, { desc = "Go to previous field (accepts count)", nowait = keymaps.popup.prev_field_nowait }) + end + end +end + +return M diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 41c00478..c5b103be 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -42,12 +42,21 @@ M.open = function() end local diffview_open_command = "DiffviewOpen" - local has_clean_tree, err = git.has_clean_tree() - if err ~= nil then - return - end - if state.settings.reviewer_settings.diffview.imply_local and has_clean_tree then - diffview_open_command = diffview_open_command .. " --imply-local" + + if state.settings.reviewer_settings.diffview.imply_local then + local has_clean_tree, err = git.has_clean_tree() + if err ~= nil then + return + end + if has_clean_tree then + diffview_open_command = diffview_open_command .. " --imply-local" + else + u.notify( + "Your working tree has changes, cannot use 'imply_local' setting for gitlab reviews.\n Stash or commit all changes to use.", + vim.log.levels.WARN + ) + state.settings.reviewer_settings.diffview.imply_local = false + end end vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) @@ -55,13 +64,6 @@ M.open = function() M.is_open = true M.tabnr = vim.api.nvim_get_current_tabpage() - if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then - u.notify( - "There are uncommited changes in the working tree, cannot use 'imply_local' setting for gitlab reviews.\n Stash or commit all changes to use.", - vim.log.levels.WARN - ) - end - if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then u.notify( "Diagnostics are now configured as settings.discussion_signs, see :h gitlab.nvim.signs-and-diagnostics", @@ -289,12 +291,16 @@ end M.execute_callback = function(callback) return function() vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { "'[V']" } }, {}) - vim.api.nvim_cmd( + local _, err = pcall( + vim.api.nvim_cmd, { cmd = "lua", args = { ("require'gitlab'.%s()"):format(callback) }, mods = { lockmarks = true } }, {} ) vim.api.nvim_win_set_cursor(M.old_winnr, M.old_cursor_position) vim.opt.operatorfunc = M.old_opfunc + if err ~= "" then + u.notify_vim_error(err, vim.log.levels.ERROR) + end end end diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 2989cd9a..e615ee32 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -116,6 +116,7 @@ M.settings = { toggle_tree_type = "i", publish_draft = "P", toggle_draft_mode = "D", + toggle_sort_method = "st", toggle_node = "t", toggle_all_discussions = "T", toggle_resolved_discussions = "R", @@ -133,15 +134,18 @@ M.settings = { popup = { width = "40%", height = "60%", + position = "50%", border = "rounded", opacity = 1.0, - edit = nil, comment = nil, + edit = nil, note = nil, help = nil, pipeline = nil, reply = nil, squash_message = nil, + create_mr = { width = "95%", height = "95%" }, + summary = { width = "95%", height = "95%" }, temp_registers = {}, }, discussion_tree = { @@ -150,15 +154,19 @@ M.settings = { collapsed = " ", indentation = " ", }, + spinner_chars = { "-", "\\", "|", "/" }, auto_open = true, default_view = "discussions", blacklist = {}, + sort_by = "latest_reply", keep_current_open = false, - position = "left", + position = "bottom", size = "20%", relative = "editor", resolved = "✓", unresolved = "-", + unlinked = "󰌸", + draft = "✎", tree_type = "simple", draft_mode = false, }, @@ -233,13 +241,17 @@ M.settings = { username = "Keyword", mention = "WarningMsg", date = "Comment", + unlinked = "DiffviewNonText", expander = "DiffviewNonText", directory = "Directory", directory_icon = "DiffviewFolderSign", file_name = "Normal", resolved = "DiagnosticSignOk", unresolved = "DiagnosticSignWarn", - draft = "DiffviewNonText", + draft = "DiffviewReference", + draft_mode = "DiagnosticWarn", + live_mode = "DiagnosticOk", + sort_method = "Keyword", }, }, } @@ -427,94 +439,6 @@ M.setPluginConfiguration = function() return true end -local function exit(popup, opts) - if opts.action_before_exit and opts.cb ~= nil then - opts.cb() - popup:unmount() - else - popup:unmount() - if opts.cb ~= nil then - opts.cb() - end - end -end - --- These keymaps are buffer specific and are set dynamically when popups mount -M.set_popup_keymaps = function(popup, action, linewise_action, opts) - if M.settings.keymaps.disable_all or M.settings.keymaps.popup.disable_all then - return - end - - if opts == nil then - opts = {} - end - if action ~= "Help" and M.settings.keymaps.help then -- Don't show help on the help popup - vim.keymap.set("n", M.settings.keymaps.help, function() - local help = require("gitlab.actions.help") - help.open() - end, { buffer = popup.bufnr, desc = "Open help", nowait = M.settings.keymaps.help_nowait }) - end - if action ~= nil and M.settings.keymaps.popup.perform_action then - vim.keymap.set("n", M.settings.keymaps.popup.perform_action, function() - local text = u.get_buffer_text(popup.bufnr) - if opts.action_before_close then - action(text, popup.bufnr) - exit(popup, opts) - else - exit(popup, opts) - action(text, popup.bufnr) - end - end, { buffer = popup.bufnr, desc = "Perform action", nowait = M.settings.keymaps.popup.perform_action_nowait }) - end - - if linewise_action ~= nil and M.settings.keymaps.popup.perform_action then - vim.keymap.set("n", M.settings.keymaps.popup.perform_linewise_action, function() - local bufnr = vim.api.nvim_get_current_buf() - local linnr = vim.api.nvim_win_get_cursor(0)[1] - local text = u.get_line_content(bufnr, linnr) - linewise_action(text) - end, { - buffer = popup.bufnr, - desc = "Perform linewise action", - nowait = M.settings.keymaps.popup.perform_linewise_action_nowait, - }) - end - - if M.settings.keymaps.popup.discard_changes then - vim.keymap.set("n", M.settings.keymaps.popup.discard_changes, function() - local temp_registers = M.settings.popup.temp_registers - M.settings.popup.temp_registers = {} - vim.cmd("quit!") - M.settings.popup.temp_registers = temp_registers - end, { - buffer = popup.bufnr, - desc = "Quit discarding changes", - nowait = M.settings.keymaps.popup.discard_changes_nowait, - }) - end - - if opts.save_to_temp_register then - vim.api.nvim_create_autocmd("BufWinLeave", { - buffer = popup.bufnr, - callback = function() - local text = u.get_buffer_text(popup.bufnr) - for _, register in ipairs(M.settings.popup.temp_registers) do - vim.fn.setreg(register, text) - end - end, - }) - end - - if opts.action_before_exit then - vim.api.nvim_create_autocmd("BufWinLeave", { - buffer = popup.bufnr, - callback = function() - exit(popup, opts) - end, - }) - 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 @@ -606,6 +530,7 @@ M.dependencies = { body = function() return { blacklist = M.settings.discussion_tree.blacklist, + sort_by = M.settings.discussion_tree.sort_by, } end, }, diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index aa71136b..48a01df0 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -122,7 +122,7 @@ M.time_since = function(date_string, current_date_table) local time_diff = current_date - date if time_diff < 60 then - return M.pluralize(time_diff, "second") .. " ago" + return "just now" elseif time_diff < 3600 then return M.pluralize(math.floor(time_diff / 60), "minute") .. " ago" elseif time_diff < 86400 then @@ -335,6 +335,11 @@ M.notify = function(msg, lvl) vim.notify("gitlab.nvim: " .. msg, lvl) end +-- Re-raise Vimscript error message after removing existing message prefixes +M.notify_vim_error = function(msg, lvl) + M.notify(msg:gsub("^Vim:", ""):gsub("^gitlab.nvim: ", ""), lvl) +end + M.get_current_line_number = function() return vim.api.nvim_call_function("line", { "." }) end @@ -427,6 +432,10 @@ M.press_enter = function() vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", false, true, true), "n", false) end +M.press_escape = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", false, true, true), "nx", false) +end + ---Return timestamp from ISO 8601 formatted date string. ---@param date_string string ISO 8601 formatted date string ---@return integer timestamp @@ -480,62 +489,6 @@ M.difference = function(a, b) return not_included end ----Get the popup view_opts ----@param title string The string to appear on top of the popup ----@param settings table|nil User defined popup settings ----@param width number? Override default width ----@param height number? Override default height ----@return table -M.create_popup_state = function(title, settings, width, height, zindex) - local default_settings = require("gitlab.state").settings.popup - local user_settings = settings or {} - local view_opts = { - buf_options = { - filetype = "markdown", - }, - relative = "editor", - enter = true, - focusable = true, - zindex = zindex or 50, - border = { - style = user_settings.border or default_settings.border, - text = { - top = title, - }, - }, - position = "50%", - size = { - width = user_settings.width or width or default_settings.width, - height = user_settings.height or height or default_settings.height, - }, - opacity = user_settings.opacity or default_settings.opacity, - } - - return view_opts -end - ----Create view_opts for Box popups used inside popup Layouts ----@param title string|nil The string to appear on top of the popup ----@param enter boolean Whether the pop should be focused after creation ----@return table -M.create_box_popup_state = function(title, enter) - local settings = require("gitlab.state").settings.popup - return { - buf_options = { - filetype = "markdown", - }, - enter = enter or false, - focusable = true, - border = { - style = settings.border, - text = { - top = title, - }, - }, - opacity = settings.opacity, - } -end - M.read_file = function(file_path, opts) local file = io.open(file_path, "r") if file == nil then @@ -634,7 +587,7 @@ end M.check_visual_mode = function() local mode = vim.api.nvim_get_mode().mode if mode ~= "v" and mode ~= "V" then - M.notify("Code suggestions are only available in visual mode", vim.log.levels.WARN) + M.notify("Code suggestions and multiline comments are only available in visual mode", vim.log.levels.WARN) return false end return true @@ -644,7 +597,7 @@ end ---Exists visual mode in order to access marks "<" , ">" ---@return integer start,integer end Start line and end line M.get_visual_selection_boundaries = function() - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", false, true, true), "nx", false) + M.press_escape() local start_line = vim.api.nvim_buf_get_mark(0, "<")[1] local end_line = vim.api.nvim_buf_get_mark(0, ">")[1] return start_line, end_line