From c7a27ca515d97106004530d2bf917c34c06b6b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 20 Aug 2026 01:12:42 +0200 Subject: [PATCH] feat(discussion_tree): implement indentation as virtual text The indentation is drawn as virtual text and the icon was only ever used for its display width, which had to be kept in sync by hand with the width of the expander icons. Take a number of columns instead. BREAKING CHANGE: discussion_tree.expanders.indentation is replaced by discussion_tree.indent_width. :checkhealth reports the removed setting. feat: draw the expander as virtual text The whole indent region is virtual now, so a buffer line holds nothing but the note's own text: yanking a header no longer picks up the expander icon and the cursor cannot sit on it. A node's text starts at depth * width, with the expander drawn inside the node's own level, which keeps headers aligned with the bodies and replies under them whatever the expander icons are. A level grows to fit an expander wider than indent_width. feat: add indent guides to the discussion tree Guides tie a discussion's replies to the note that started it, with the last reply closing the thread, so that separate discussions can be told apart at a glance. They are drawn as part of the virtual indentation and so cost no columns and never enter the buffer. Only a discussion's root note produces them: a reply's body is not joined to the reply's own header. Set discussion_tree.indent_guides to false to turn off. fixup: only insert indents on wrapped lines when 'wrap' is on --- after/syntax/gitlab.vim | 18 +- doc/gitlab.nvim.txt | 19 +- lua/gitlab/actions/discussions/init.lua | 120 +++++- lua/gitlab/actions/discussions/tree.lua | 27 +- .../actions/discussions/virtual_indent.lua | 375 ++++++++++++++++++ lua/gitlab/annotations.lua | 14 +- lua/gitlab/colors.lua | 4 +- lua/gitlab/health.lua | 4 + lua/gitlab/state.lua | 13 +- 9 files changed, 546 insertions(+), 48 deletions(-) create mode 100644 lua/gitlab/actions/discussions/virtual_indent.lua diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index 5922f296b..e376a6709 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -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' @@ -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\)\@` spaces therefore fills the current line to its edge and re-indents the +-- continuation, without a single character entering the buffer. The technique is +-- borrowed from this PR to nvim-orgmode: +-- https://github.com/nvim-orgmode/orgmode/pull/1017 + +local state = require("gitlab.state") + +local M = {} + +local ns = vim.api.nvim_create_namespace("gitlab_discussion_tree_indent") + +---A tuple of `text` and optional `highlight`. +---@alias VirtText {[1]: string, [2]: string?} + +---Return the number of display columns one level of nesting takes up. +---This is the configured `indent_width`, but cannot be less than the width of the +---expanders which are drawn inside the node's own level. +---@return integer +local function level_width() + local settings = state.settings.discussion_tree + return math.max( + settings.indent_width, + vim.fn.strdisplaywidth(settings.expanders.expanded), + vim.fn.strdisplaywidth(settings.expanders.collapsed) + ) +end + +---Pad `text` with spaces to exactly `width` display columns. +---Returns `text` unchanged when it is already at least that wide. +---@param text string +---@param width integer +---@return string +local function pad(text, width) + return text .. string.rep(" ", math.max(width - vim.fn.strdisplaywidth(text), 0)) +end + +---A block of `width` columns of plain padding. +---@param width integer +---@return VirtText +local function padding(width) + return { string.rep(" ", width) } +end + +---The guide block the replies carry at the level of the note that started the thread. +---`nil` when guides are turned off. +---@param width integer +---@param kind "vertical"|"branch"|"last" +---@return VirtText? +local function guide(width, kind) + local guides = state.settings.discussion_tree.indent_guides + if not guides or guides[kind] == nil or guides[kind] == "" then + return nil + end + local fill = kind == "vertical" and " " or (guides.horizontal ~= "" and guides.horizontal or " ") + local block = guides[kind] + while vim.fn.strdisplaywidth(block) < width do + block = block .. fill + end + return { block, "GitlabIndentGuide" } +end + +---The virtual text that indents one line of a node, as a list of VirtText chunks. +--- +---A node's text starts at `depth * level_width()`, and the indentation is that many columns +---made of `depth` blocks, one per level of nesting. The last block belongs to the node itself +---and holds its expander when it has children; the blocks before it are its ancestors', and +---carry a guide only for the note that started the discussion. Drawing the expander inside a +---block rather than in front of the text is what keeps a note's header aligned with the bodies +---and replies underneath it, whatever the expander icons are. +---@param node NuiTree.Node +---@param ancestors table[] One block per ancestor level, innermost last +---@param width integer The width of one level, from `level_width()` +---@param expander boolean Whether to draw the node's expander, i.e. whether this is the line its text starts on +---@return VirtText[] +local function indent_chunks(node, ancestors, width, expander) + local blocks = {} + for _, block in ipairs(ancestors) do + table.insert(blocks, block.chunk or padding(width)) + end + + local expanders = state.settings.discussion_tree.expanders + if node:has_children() and expander then + local icon = node:is_expanded() and expanders.expanded or expanders.collapsed + table.insert(blocks, { pad(icon, width), "GitlabExpander" }) + else + table.insert(blocks, padding(width)) + end + + return blocks +end + +---The same blocks, as they are drawn on the lines *below* the one a node's text starts on: its +---own wrapped lines and everything nested under it. Only the innermost block changes, from the +---branch that ties a reply to its discussion to the plain vertical that carries on past it. +---@param ancestors table[] +---@return table[] +local function below(ancestors) + local result = vim.list_extend({}, ancestors) + local innermost = result[#result] + if innermost ~= nil and innermost.branch then + result[#result] = { chunk = innermost.below } + end + return result +end + +---Work out the block each child of `node` carries at `node`'s own level, and the block its +---descendants carry below it. +--- +---Only a discussion's root note produces guides: a reply is tied to the note it answers, and +---nothing else is. Every other level pads, so a reply's body is not joined to the reply's own +---header. +---@param node NuiTree.Node +---@param children NuiTree.Node[] +---@param width integer +---@return table keyed by child node id +local function child_blocks(node, children, width) + local blocks = {} + if not node.is_root then + for _, child in ipairs(children) do + blocks[child:get_id()] = {} + end + return blocks + end + + local last_reply + for _, child in ipairs(children) do + if child.type == "note" then + last_reply = child:get_id() + end + end + + local seen_last = false + for _, child in ipairs(children) do + local id = child:get_id() + if child.type == "note" then + local is_last = id == last_reply + blocks[id] = { + chunk = guide(width, is_last and "last" or "branch"), + below = not is_last and guide(width, "vertical") or nil, + branch = true, + } + seen_last = is_last + else + -- A body line of the note itself: the discussion continues below it whenever a reply + -- follows, and there is nothing left to join to once the last one has been passed. + blocks[id] = { chunk = (last_reply ~= nil and not seen_last) and guide(width, "vertical") or nil } + end + end + return blocks +end + +---Virtual text that indents a wrapped line by `width` columns, opening it with the configured +---`wrap_marker` symbol. Unlike Vim's 'showbreak', the symbol is drawn inside the indentation +---rather than in front of the text, so it costs no columns and the text stays aligned. It is +---dropped if it would not fit within the indentation. +---@param chunks string[][] The node's indentation, from `indent_chunks` +---@param width integer Total display columns the chunks take up +---@return string[][] +local function wrapped_indent(chunks, width) + local wrap_marker = state.settings.discussion_tree.wrap_marker + local symbol = vim.fn.strdisplaywidth(wrap_marker) + if wrap_marker == "" or symbol > width then + return chunks + end + -- Trim the marker's width off the end of the indentation and put the marker there, so that it + -- costs no columns and the text it precedes stays where it would otherwise be. + local marked = {} + local remaining = width - symbol + for _, chunk in ipairs(chunks) do + local chunk_width = vim.fn.strdisplaywidth(chunk[1]) + if remaining >= chunk_width then + table.insert(marked, chunk) + remaining = remaining - chunk_width + elseif remaining > 0 then + table.insert(marked, { vim.fn.strcharpart(chunk[1], 0, remaining), chunk[2] }) + remaining = 0 + end + end + table.insert(marked, { wrap_marker, "GitlabWrapMarker" }) + return marked +end + +---Split a line the way 'linebreak' does: into chunks that each end with the characters a line +---may be broken after. A chunk is therefore a word together with the punctuation or whitespace +---trailing it, which is what has to fit on a screen line for the word to stay on it. +---@param line string +---@param breakat table +---@return {byte: integer, width: integer}[] +local function chunks(line, breakat) + -- Every character in \'breakat\' is ASCII, and no byte of a multibyte character ever is, so + -- this can scan bytes without splitting a character. + local list = {} + local pos = 1 + while pos <= #line do + local start = pos + while pos <= #line and not breakat[line:sub(pos, pos)] do + pos = pos + 1 + end + while pos <= #line and breakat[line:sub(pos, pos)] do + pos = pos + 1 + end + table.insert(list, { byte = start, width = vim.fn.strdisplaywidth(line:sub(start, pos - 1)) }) + end + return list +end + +---Find the points at which Neovim will soft-wrap `line`, filling screen lines with as many +---whole chunks as fit, exactly as \'linebreak\' does. +--- +---Only the chunks that start a line are returned; the caller draws the indentation in front of +---each of them. A chunk too wide for a line of its own is left alone, since Neovim then has to +---break it mid-chunk, at a point no virtual text can usefully be attached to. +---@param line string +---@param width integer Display columns available to the text itself on the first screen line +---@param indent integer Display columns the tree indents the line by +---@param lead integer Display columns of the line\'s own leading whitespace, which a wrapped line is indented by on top of the tree\'s indentation and so cannot use for text +---@param wrap boolean Whether the 'wrap' option is on +---@return integer[] byte 0-based byte positions of the chunks that start a wrapped line +local function wrap_points(line, width, indent, lead, breakat, wrap) + local points = {} + if width < 1 or width - lead < 1 or not wrap then + return points + end + + -- How much room a screen line has depends on what is drawn in front of it: the first line and + -- every line this function marks give up columns to the indentation, while a line it leaves + -- alone -- one holding a chunk too wide to be indented and still fit -- has the window to + -- itself. + local hanging = width - lead + local full = width + indent + + local column = 0 + local capacity = width + + for _, chunk in ipairs(chunks(line, breakat)) do + if column > 0 and column + chunk.width > capacity then + -- The chunk cannot finish this line, so Neovim moves it down whole. Indenting it is only + -- worth doing when it still fits afterwards; otherwise it is left flush. + if chunk.width <= hanging then + table.insert(points, chunk.byte - 1) + capacity = hanging + else + capacity = full + end + column = chunk.width + else + column = column + chunk.width + end + + if column > capacity then + -- Wider than the line it starts on, so Neovim has to break it mid-chunk. What it spills + -- onto carries no indentation of ours, and so has the full width. + column = (column - capacity) % full + capacity = full + end + end + + return points +end + +---Display columns the text of a window has to itself, or nil if the buffer is not on screen. +---@param winid integer +---@return integer +local function text_width(winid) + local win = vim.fn.getwininfo(winid)[1] + -- `textoff` covers the sign, number and fold columns, which the text cannot use. + return win.width - win.textoff +end + +---Draw the indentation of every visible node. +---Draws indentation on the node's first screen line and, if 'wrap' is set, on each line +---it wraps onto. +---Safe to call repeatedly; each call replaces the marks of the one before. +---@param tree? NuiTree +M.apply = function(tree) + if tree == nil then + return + end + local bufnr = tree.bufnr + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + + local winid = vim.fn.bufwinid(bufnr) + + local width = winid > -1 and text_width(winid) or nil + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local level = level_width() + + -- Only insert indentation when 'wrap' is set for the window. Default to true. + local wrap = true + if winid > -1 then + wrap = vim.wo[winid].wrap + end + + local breakat = {} + local option = vim.o.breakat + for i = 1, #option do + breakat[option:sub(i, i)] = true + end + + ---Draw `node`, then everything under it. + ---The tree is walked in order rather than by id, because a node's indentation depends + ---on where its ancestors sit among their own siblings. + ---@param node NuiTree.Node + ---@param ancestors table[] One block per ancestor level, innermost last + local function draw(node, ancestors) + local _, start_linenr, end_linenr = tree:get_node(node:get_id()) + local indent = node._depth * level + + local beneath = below(ancestors) + if start_linenr and indent > 0 then + local prefix = indent_chunks(node, ancestors, level, true) + local continuation = indent_chunks(node, beneath, level, false) + for linenr = start_linenr, end_linenr or start_linenr do + vim.api.nvim_buf_set_extmark(bufnr, ns, linenr - 1, 0, { + virt_text = linenr == start_linenr and prefix or continuation, + virt_text_pos = "inline", + right_gravity = false, + }) + + -- With 'linebreak' set, Neovim keeps inline virtual text together with the chunk it + -- sits in front of. A mark on a chunk that starts a wrapped line therefore moves down + -- with it and is drawn at the very start of that screen line, which is exactly where + -- the indentation belongs. + local line = lines[linenr] + if width ~= nil and line ~= nil then + -- Wrapped lines hang under the text they continue, so they are indented by the + -- line's own leading whitespace as well, the way 'breakindent' would do it. + local lead = vim.fn.strdisplaywidth(line:match("^%s*")) + local hanging = vim.list_extend(vim.deepcopy(continuation), { padding(lead) }) + local virt_text = wrapped_indent(hanging, indent + lead) + for _, byte in ipairs(wrap_points(line, width - indent, indent, lead, breakat, wrap)) do + vim.api.nvim_buf_set_extmark(bufnr, ns, linenr - 1, byte, { + virt_text = virt_text, + virt_text_pos = "inline", + right_gravity = false, + }) + end + end + end + end + + if not node:is_expanded() then + return + end + local children = tree:get_nodes(node:get_id()) + local blocks = child_blocks(node, children, level) + for _, child in ipairs(children) do + local inherited = vim.list_extend({}, beneath) + table.insert(inherited, blocks[child:get_id()] or {}) + draw(child, inherited) + end + end + + for _, node in ipairs(tree:get_nodes()) do + draw(node, {}) + end +end + +return M diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 7d66129c5..07c52621f 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -194,6 +194,8 @@ ---@field date? string ---@field unlinked? string ---@field expander? string +---@field wrap_marker? string +---@field indent_guide? string ---@field directory? string ---@field directory_icon? string ---@field file_name? string @@ -319,6 +321,9 @@ ---@field draft? string Symbol to show next to draft comments/notes ---@field tree_type? "simple"|"by_file_name" Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file ---@field draft_mode? boolean Whether comments are posted as drafts as part of a review +---@field indent_width? integer Display columns one level of nesting indents by +---@field indent_guides? IndentGuides Guides tying replies to the initial comment. +---@field wrap_marker? string Marker drawn at the start of a wrapped line ---@field relative_date? boolean Whether to show relative time like "5 days ago" or absolute time like "03/01/2025 at 01:43" ---@field winopts? GitlabDiscussionsWinopts Window-local options for the discussion tree split ---@field winbar? function Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) @@ -330,13 +335,16 @@ ---@class ExpanderOpts: table ---@field expanded? string Icon for expanded discussion thread ---@field collapsed? string Icon for collapsed discussion thread ----@field indentation? string Indentation Icon + +---@class IndentGuides +---@field vertical? string Drawn down a discussion that has more replies below +---@field branch? string Drawn against a reply's header +---@field last? string Drawn against the header of the last reply +---@field horizontal? string Pads `branch` and `last` out to the indent width ---@class GitlabDiscussionsWinopts ---@field number? boolean Show line numbers ---@field relativenumber? boolean Show relative line numbers ----@field breakindent? boolean Every wrapped line will continue visually indented ----@field showbreak? string String to put at the start of lines that have been wrapped ---@class Keymaps ---@field disable_all boolean Disable all mappings created by the plugin diff --git a/lua/gitlab/colors.lua b/lua/gitlab/colors.lua index bf29dec36..0e37631b5 100644 --- a/lua/gitlab/colors.lua +++ b/lua/gitlab/colors.lua @@ -5,8 +5,6 @@ local state = require("gitlab.state") -- vim.g.gitlab_discussion_tree and accessing it as a vim dictionary in -- after/syntax/gitlab.vim. 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 @@ -25,6 +23,8 @@ vim.api.nvim_create_autocmd({ "VimEnter", "ColorScheme" }, { vim.api.nvim_set_hl(0, "GitlabMention", get_colors_for_group(discussion_colors.mention)) vim.api.nvim_set_hl(0, "GitlabDate", get_colors_for_group(discussion_colors.date)) vim.api.nvim_set_hl(0, "GitlabExpander", get_colors_for_group(discussion_colors.expander)) + vim.api.nvim_set_hl(0, "GitlabWrapMarker", get_colors_for_group(discussion_colors.wrap_marker)) + vim.api.nvim_set_hl(0, "GitlabIndentGuide", get_colors_for_group(discussion_colors.indent_guide)) vim.api.nvim_set_hl(0, "GitlabDirectory", get_colors_for_group(discussion_colors.directory)) vim.api.nvim_set_hl(0, "GitlabDirectoryIcon", get_colors_for_group(discussion_colors.directory_icon)) vim.api.nvim_set_hl(0, "GitlabFileName", get_colors_for_group(discussion_colors.file_name)) diff --git a/lua/gitlab/health.lua b/lua/gitlab/health.lua index ad153ec9d..33b238f56 100644 --- a/lua/gitlab/health.lua +++ b/lua/gitlab/health.lua @@ -87,6 +87,7 @@ M.check = function(return_results) "discussion_tree.delete_comment", "discussion_tree.delete_emoji", "discussion_tree.edit_comment", + "discussion_tree.expanders.indentation", "discussion_tree.jump_to_file", "discussion_tree.jump_to_reviewer", "discussion_tree.open_in_browser", @@ -101,6 +102,9 @@ M.check = function(return_results) "discussion_tree.toggle_resolved_discussions", "discussion_tree.toggle_tree_type", "discussion_tree.toggle_unresolved_discussions", + "discussion_tree.winopts.breakindent", + "discussion_tree.winopts.linebreak", + "discussion_tree.winopts.showbreak", "help", "popup.keymaps.next_field", "popup.keymaps.prev_field", diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 0d25b264c..e773ff548 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -178,8 +178,15 @@ M.settings = { expanders = { expanded = " ", collapsed = " ", - indentation = " ", }, + indent_width = 2, + indent_guides = { + vertical = "│", + branch = "├", + last = "╰", + horizontal = "─", + }, + wrap_marker = "↪ ", spinner_chars = { "-", "\\", "|", "/" }, auto_open = true, focus_on_open = true, @@ -200,8 +207,6 @@ M.settings = { winopts = { number = false, relativenumber = false, - breakindent = true, - showbreak = "+ ", }, }, emojis = { @@ -313,6 +318,8 @@ M.settings = { date = "Comment", unlinked = "DiffviewNonText", expander = "DiffviewNonText", + wrap_marker = "DiffviewNonText", + indent_guide = "DiffviewNonText", directory = "Directory", directory_icon = "DiffviewFolderSign", file_name = "Normal",