Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions after/syntax/gitlab.vim
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
if filereadable($VIMRUNTIME . '/syntax/markdown.vim')
" Don't source the markdown syntax again when reloading this file after Treesitter
" highlighting is started, see `actions/discussions/init.lua`.
if !exists("b:markdown_syntax_loaded") && filereadable($VIMRUNTIME . '/syntax/markdown.vim')
source $VIMRUNTIME/syntax/markdown.vim
let b:markdown_syntax_loaded = 1
endif

let expanders = '^\s*\%(' . g:gitlab_discussion_tree_expander_open . '\|' . g:gitlab_discussion_tree_expander_closed . '\)'
let username = '@[a-zA-Z0-9.]\+'
let username = '@[a-zA-Z0-9._]\+'

" Covers times like '14 days ago', 'just now', as well as 'October 3, 2024', and '02/28/2025 at 00:50'
let time_ago = '\d\+ \w\+ ago'
Expand All @@ -14,15 +16,19 @@ let date = '\%(' . time_ago . '\|' . formatted_date . '\|' . absolute_time . '\|
let published = date . ' \%(' . g:gitlab_discussion_tree_resolved . '\|' . g:gitlab_discussion_tree_unresolved . '\|' . g:gitlab_discussion_tree_unlinked . '\)\?'
let state = ' \%(' . published . '\|' . g:gitlab_discussion_tree_draft . '\)'

execute 'syntax match GitlabNoteHeader "' . expanders . username . state . '" contains=GitlabDate,GitlabUnresolved,GitlabUnlinked,GitlabResolved,GitlabExpander,GitlabDraft,GitlabUsername'
" Require that the `@` in a GitlabMention is not preceded by a word character, so that
" @doe.com in john@doe.com is not highlighted as a mention.
execute 'syntax match GitlabMention "\(\w\)\@<!' . username . '"'

" The tree draws indentation and the expander icons as virtual text, so a note's header
" starts at the very beginning of the line.
execute 'syntax match GitlabNoteHeader "^' . username . state . '" contains=GitlabDate,GitlabUnresolved,GitlabUnlinked,GitlabResolved,GitlabDraft,GitlabUsername'

execute 'syntax match GitlabDate "' . date . '" contained'
execute 'syntax match GitlabUnresolved "' . g:gitlab_discussion_tree_unresolved . '" contained'
execute 'syntax match GitlabUnlinked "' . g:gitlab_discussion_tree_unlinked . '" contained'
execute 'syntax match GitlabResolved "' . g:gitlab_discussion_tree_resolved . '" contained'
execute 'syntax match GitlabExpander "' . expanders . '" contained'
execute 'syntax match GitlabDraft "' . g:gitlab_discussion_tree_draft . '" contained'
execute 'syntax match GitlabUsername "' . username . '" contained'
execute 'syntax match GitlabMention "' . username . '"'

let b:current_syntax = 'gitlab'
19 changes: 16 additions & 3 deletions doc/gitlab.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,15 @@ you call this function with no values the defaults will be used:
expanders = { -- Discussion tree icons
expanded = " ", -- Icon for expanded discussion thread
collapsed = " ", -- Icon for collapsed discussion thread
indentation = " ", -- Indentation Icon
},
indent_width = 2, -- Display columns that one level of nesting indents by. Indentation is drawn as virtual text, so it never enters the buffer and is not copied along with note text
indent_guides = { -- Guides tying replies to the initial comment. The characters are padded out to `indent_width`. Set `indent_guides = false` to turn the guides off
vertical = "│", -- Drawn down a discussion that has more replies below
branch = "├", -- Drawn against a reply's header
last = "╰", -- Drawn against the header of the last reply
horizontal = "─", -- Pads `branch` and `last` out to the indent width
},
wrap_marker = "↪ ", -- Marker drawn inside the tree's indentation at the start of a wrapped line. Dropped if wider than the indentation. Set `wrap_marker = ""` to turn the marker off
spinner_chars = { "/", "|", "\\", "-" }, -- Characters for the refresh animation
auto_open = true, -- Automatically open when the reviewer is opened
focus_on_open = true, -- Automatically focus the discussion tree when it is opened
Expand All @@ -348,8 +355,12 @@ you call this function with no values the defaults will be used:
winopts = { -- Window-local options for the discussion tree split
number = false,
relativenumber = false,
breakindent = true, -- Every wrapped line will continue visually indented
showbreak = "+ ", -- String to put at the start of lines that have been wrapped
-- The tree indents lines with virtual text and enforces 'breakindent=false',
-- 'linebreak=true', and 'showbreak=' because other values ruin the indentation
-- on wrapped lines. Use discussion_tree.wrap_marker instead of 'showbreak'.
-- The global 'breakat' option which controls at which characters lines can wrap
-- cannot be set per-window. If you want to enable soft-wrapping for long Gitlab
-- URLS, add this to your global configuration: `set breakat+==#`.
}
winbar = nil, -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua)
-- If using lualine, add "gitlab" to disabled file types, otherwise you will not see the winbar.
Expand Down Expand Up @@ -477,6 +488,8 @@ you call this function with no values the defaults will be used:
mention = "WarningMsg",
date = "Comment",
expander = "DiffviewNonText",
wrap_marker = "DiffviewNonText",
indent_guide = "DiffviewNonText",
directory = "Directory",
directory_icon = "DiffviewFolderSign",
file_name = "Normal",
Expand Down
120 changes: 101 additions & 19 deletions lua/gitlab/actions/discussions/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ local common = require("gitlab.actions.common")
local List = require("gitlab.utils.list")
local tree_utils = require("gitlab.actions.discussions.tree")
local discussions_tree = require("gitlab.actions.discussions.tree")
local virtual_indent = require("gitlab.actions.discussions.virtual_indent")
local draft_notes = require("gitlab.actions.draft_notes")
local signs = require("gitlab.indicators.signs")
local diagnostics = require("gitlab.indicators.diagnostics")
local winbar = require("gitlab.actions.discussions.winbar")
local help = require("gitlab.actions.help")
local emoji = require("gitlab.emoji")

local indent_group = vim.api.nvim_create_augroup("gitlab.discussions.indentation", {})

local M = {
split_visible = false,
split = nil,
Expand All @@ -33,8 +36,25 @@ local M = {
discussion_tree = nil,
---@type NuiTree?
unlinked_discussion_tree = nil,
---@type ("discussions"|"notes")?
current_view_type = nil,
}

---Attach Treesitter's markdown parser to a discussion tree buffer.
---This enables code blocks (e.g. ```python) to get real language-specific syntax
---highlighting, instead of the legacy regex syntax defined for the `gitlab` filetype in
---`after/syntax/gitlab.vim`. A no-op if the `markdown` parser isn't installed.
---@param bufnr number
local function attach_markdown_treesitter(bufnr)
vim.treesitter.language.register("markdown", "gitlab")
if pcall(vim.treesitter.start, bufnr, "markdown") then
-- `vim.treesitter.start()` clears 'syntax' on the buffer but that also wipes the
-- note header highlighting, which the markdown parser knows nothing about. Reload
-- it on top (markdown regex syntax is skipped in that case).
vim.bo[bufnr].syntax = "ON"
end

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this also reload the markdown syntax? Most of the groups are gone anyway...

end

---Delete discussion buffers to prevent leaked buffers on each M.open/M.close cycle.
---@param split_bufnr integer? Passed in because `unmount` has already nil'd `M.split.bufnr`.
local function delete_bufs(split_bufnr)
Expand All @@ -46,6 +66,29 @@ local function delete_bufs(split_bufnr)
end
end

---Return the currently selected tree.
---Nil if the split is not a valid window or the window is not in the current tabpage.
---@preturn tree? NuiTree
local get_current_tree = function()
if M.split == nil or not (M.split.winid and vim.api.nvim_win_is_valid(M.split.winid)) then
return
end
local win_in_tabpage = false
for _, i in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if i == M.split.winid then
win_in_tabpage = true
end
end
if not win_in_tabpage then
return
end
if M.current_view_type == "discussions" then
return M.discussion_tree
elseif M.current_view_type == "notes" then
return M.unlinked_discussion_tree
end
end

---Re-fetch all discussions and re-render the relevant view.
---TODO: simplify the function signature - "unlinked" and "all" should not be two booleans
---@param unlinked boolean
Expand Down Expand Up @@ -142,13 +185,11 @@ M.open = function(callback, view_type)
M.split_visible = true
split:mount()

-- Set window and buffer local options to discussion tree split after mounting the split
for opt, val in pairs(state.settings.discussion_tree.winopts) do
vim.api.nvim_set_option_value(opt, val, { win = M.split.winid })
end

-- Set buffer-local options to discussion tree buffers.
vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr })
vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr })
attach_markdown_treesitter(M.linked_bufnr)
attach_markdown_treesitter(M.unlinked_bufnr)

-- Set autocmds to clean up state when discussions buffers are deleted manually
vim.api.nvim_create_autocmd("BufWipeout", {
Expand All @@ -164,6 +205,27 @@ M.open = function(callback, view_type)
end,
})

-- Where a line wraps depends on the window width, so a resize has to redraw the
-- indentation of the wrapped lines. The buffer's contents do not change, so the tree
-- needs no re-render.
-- vim.api.nvim_create_autocmd({ "WinResized", "VimResized", "TabEnter" }, {
vim.api.nvim_create_autocmd({ "WinResized", "VimResized" }, {
-- TODO: Find out why "WinResized" fires three times after toggling a node.
callback = function()
virtual_indent.apply(get_current_tree())
end,
desc = "Re-apply virtual indentation in discussion tree",
group = indent_group,
})
vim.api.nvim_create_autocmd("OptionSet", {
pattern = { "wrap", "breakat", "number", "relativenumber" },
callback = function()
virtual_indent.apply(get_current_tree())
end,
desc = "Re-apply virtual indentation in discussion tree when options change",
group = indent_group,
})

-- Set autocmd to clean up state when discussions split is closed manually
vim.api.nvim_create_autocmd("WinClosed", {
pattern = tostring(M.split.winid),
Expand Down Expand Up @@ -224,8 +286,10 @@ M.close = function()
end)
M.split_visible = false
M.discussion_tree = nil
M.unlinked_discussion_tree = nil
delete_bufs(split_bufnr)
winbar.cleanup_timer()
vim.api.nvim_clear_autocmds({ group = indent_group })
end

---Move to the discussion tree at the discussion from diagnostic on current line.
Expand All @@ -250,7 +314,7 @@ M.move_to_discussion_tree = function()
end
discussion_node:expand()
end
M.discussion_tree:render()
discussions_tree.render(M.discussion_tree)
vim.api.nvim_set_current_win(M.split.winid)
M.switch_view_type("discussions")
vim.api.nvim_win_set_cursor(M.split.winid, { line_number, 0 })
Expand Down Expand Up @@ -525,21 +589,20 @@ M.rebuild_discussion_tree = function()
-- Combine inline draft notes with regular comments
local all_nodes = u.join(draft_comment_nodes, existing_comment_nodes)

local discussion_tree = NuiTree({
M.discussion_tree = NuiTree({
nodes = all_nodes,
bufnr = M.linked_bufnr,
prepare_node = tree_utils.nui_tree_prepare_node,
})

-- Re-expand already expanded nodes
for _, id in ipairs(expanded_node_ids) do
tree_utils.open_node_by_id(discussion_tree, id)
tree_utils.open_node_by_id(M.discussion_tree, id)
end
discussion_tree:render()
discussions_tree.restore_cursor_position(M.split.winid, discussion_tree, current_cursor_column, current_node, nil)
discussions_tree.render(M.discussion_tree)
discussions_tree.restore_cursor_position(M.split.winid, M.discussion_tree, current_cursor_column, current_node, nil)

M.set_tree_keymaps(discussion_tree, M.linked_bufnr, false)
M.discussion_tree = discussion_tree
M.set_tree_keymaps(M.discussion_tree, M.linked_bufnr, false)
common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr)
state.discussion_tree.resolved_expanded = false
state.discussion_tree.unresolved_expanded = false
Expand All @@ -564,21 +627,25 @@ M.rebuild_unlinked_discussion_tree = function()
-- Combine draft notes with regular notes
local all_nodes = u.join(draft_comment_nodes, existing_note_nodes)

local unlinked_discussion_tree = NuiTree({
M.unlinked_discussion_tree = NuiTree({
nodes = all_nodes,
bufnr = M.unlinked_bufnr,
prepare_node = tree_utils.nui_tree_prepare_node,
})

-- Re-expand already expanded nodes
for _, id in ipairs(expanded_node_ids) do
tree_utils.open_node_by_id(unlinked_discussion_tree, id)
end
unlinked_discussion_tree:render()
discussions_tree.restore_cursor_position(M.split.winid, unlinked_discussion_tree, current_cursor_column, current_node)
tree_utils.open_node_by_id(M.unlinked_discussion_tree, id)
end
discussions_tree.render(M.unlinked_discussion_tree)
discussions_tree.restore_cursor_position(
M.split.winid,
M.unlinked_discussion_tree,
current_cursor_column,
current_node
)

M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true)
M.unlinked_discussion_tree = unlinked_discussion_tree
M.set_tree_keymaps(M.unlinked_discussion_tree, M.unlinked_bufnr, true)
common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr)
state.unlinked_discussion_tree.resolved_expanded = false
state.unlinked_discussion_tree.unresolved_expanded = false
Expand Down Expand Up @@ -876,7 +943,22 @@ M.switch_view_type = function(override)
vim.api.nvim_set_current_buf(M.unlinked_bufnr)
end
vim.api.nvim_set_option_value("winfixbuf", true, { win = M.split.winid })

-- Set window local options to the discussion tree split. This needs to be done after
-- switching the view type because window-local options are in fact tied to the
-- buffer as well: see https://github.com/neovim/neovim/issues/11525.
local winopts = u.merge(state.settings.discussion_tree.winopts, {
-- These are required to make the virtual indentation work
breakindent = false,
linebreak = true,
showbreak = "NONE",
})
for opt, val in pairs(winopts) do
vim.api.nvim_set_option_value(opt, val, { win = M.split.winid })
end

winbar.update_winbar()
virtual_indent.apply(get_current_tree())
end

---Toggle comments tree type between "simple" and "by_file_name".
Expand Down
27 changes: 15 additions & 12 deletions lua/gitlab/actions/discussions/tree.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

local u = require("gitlab.utils")
local common = require("gitlab.actions.common")
local virtual_indent = require("gitlab.actions.discussions.virtual_indent")
local List = require("gitlab.utils.list")
local state = require("gitlab.state")
local NuiTree = require("nui.tree")
Expand Down Expand Up @@ -345,17 +346,10 @@ M.nui_tree_prepare_node = function(node)

for i, text in ipairs(texts) do
local line = NuiLine()
local expanders = state.settings.discussion_tree.expanders

line:append(string.rep(expanders.indentation, node._depth - 1))

if i == 1 and node:has_children() then
line:append(node:is_expanded() and expanders.expanded or expanders.collapsed)
if node.icon then
line:append(node.icon .. " ", node.icon_hl)
end
else
line:append(expanders.indentation)
-- Draw the filetype icon in the `by_file_name` tree type
if i == 1 and node:has_children() and node.icon then
line:append(node.icon .. " ", node.icon_hl)
end

line:append(text, node.text_hl)
Expand Down Expand Up @@ -384,6 +378,15 @@ M.nui_tree_prepare_node = function(node)
return lines
end

---Render the tree and redraw its virtual-text indentation and indent guides.
---Use this instead of calling `tree:render()` directly, since the indentation lives on
---a separate namespace that `tree:render()` does not clear or repopulate on its own.
---@param tree NuiTree
M.render = function(tree)
tree:render()
virtual_indent.apply(tree)
end

---@class ToggleNodesOptions
---@field toggle_resolved boolean Whether to toggle resolved discussions.
---@field toggle_unresolved boolean Whether to toggle unresolved discussions.
Expand Down Expand Up @@ -439,7 +442,7 @@ M.toggle_nodes = function(winid, tree, unlinked, opts)
state.discussion_tree.unresolved_expanded = not state.discussion_tree.unresolved_expanded
end
end
tree:render()
M.render(tree)
M.restore_cursor_position(winid, tree, current_cursor_column, current_node, root_node)
end

Expand Down Expand Up @@ -564,7 +567,7 @@ M.toggle_node = function(winid, tree)
node:expand()
end

tree:render()
M.render(tree)
M.restore_cursor_position(winid, tree, current_cursor_column, node, common.get_root_node(tree, node))
end

Expand Down
Loading
Loading