Files
outline.nvim/lua/outline/sidebar.lua
hedy 19d876246c fix: Outline auto-update
- Typo in checking for buflisted.
- As it turns out bufhidden isn't boolean, so let's not even consider it
  for now. Doing this still allows buffers like LspInfo to be ignored,
  it seems. In the future we will have configurable ignored buffers.

Thanks to @Djedouas for bringing it up.
2023-12-12 20:24:16 +08:00

852 lines
24 KiB
Lua

local Preview = require('outline.preview')
local View = require('outline.view')
local cfg = require('outline.config')
local folding = require('outline.folding')
local parser = require('outline.parser')
local providers = require('outline.providers.init')
local symbols = require('outline.symbols')
local utils = require('outline.utils.init')
local strlen = vim.fn.strlen
---@class outline.Sidebar
local Sidebar = {}
---@class outline.SidebarCodeState
---@field win integer
---@field buf integer
---@class outline.Sidebar
---@field id integer
---@field view outline.View
---@field items outline.Symbol[]
---@field flats outline.FlatSymbol[]
---@field hovered outline.FlatSymbol[]
---@field original_cursor string
---@field code outline.SidebarCodeState
---@field augroup integer
---@field provider outline.Provider?
---@field preview outline.Preview|outline.LivePreview
function Sidebar:new(id)
return setmetatable({
id = id,
view = View:new(),
preview = Preview:new(cfg.o.preview_window),
code = { buf = 0, win = 0 },
items = {},
flats = {},
hovered = {},
original_cursor = vim.o.guicursor,
}, { __index = Sidebar })
end
function Sidebar:delete_autocmds()
if self.augroup then
vim.api.nvim_del_augroup_by_id(self.augroup)
end
self.augroup = nil
end
function Sidebar:reset_state()
self.code = { buf = 0, win = 0 }
self.items = {}
self.flats = {}
self.original_cursor = vim.o.guicursor
self.provider = nil
self:delete_autocmds()
end
function Sidebar:destroy()
self:delete_autocmds()
self.view = nil
self.preview = nil
self.items = nil
self.flats = nil
self.code = nil
self.provider = nil
end
---@param opts table
function Sidebar:initial_setup(opts)
self.code.win = vim.api.nvim_get_current_win()
self.code.buf = vim.api.nvim_get_current_buf()
local sc = opts.split_command or cfg.get_split_command()
self.view:setup_view(sc)
-- clear state when buffer is closed
vim.api.nvim_buf_attach(self.view.buf, false, {
on_detach = function(_, _)
self:reset_state()
end,
})
self:setup_keymaps()
self:setup_buffer_autocmd()
self:setup_attached_buffer_autocmd()
end
---Handler for provider request_symbols when outline is opened for the first time.
---@param response outline.ProviderSymbol[]?
---@param opts outline.OutlineOpts?
function Sidebar:initial_handler(response, opts)
if response == nil or type(response) ~= 'table' or self.view:is_open() then
return
end
if not opts then
opts = {}
end
self:initial_setup(opts)
local items = parser.parse(response, self.code.buf)
self.items = items
self:_update_lines(true)
if not cfg.o.outline_window.focus_on_open or not opts.focus_outline then
vim.fn.win_gotoid(self.code.win)
end
end
-- stylua: ignore start
---Convenience function for setup_keymaps
---@param cfg_name string Field in cfg.o.keymaps
---@param method string|function If string, field in Sidebar
---@param args any[] Passed to method
function Sidebar:nmap(cfg_name, method, args)
local keys = cfg.o.keymaps[cfg_name]
local fn
if type(method) == 'string' then
fn = function() Sidebar[method](self, unpack(args)) end
else
fn = function() method(unpack(args)) end
end
for _, key in ipairs(keys) do
vim.keymap.set( 'n', key, fn,
{ silent = true, noremap = true, buffer = self.view.buf }
)
end
end
function Sidebar:setup_keymaps()
for name, meth in pairs({
show_help = { require('outline.help').show_keymap_help, {} },
close = { function() self:close() end, {} },
goto_location = { '_goto_location', { true } },
peek_location = { '_goto_location', { false } },
restore_location = { '_map_follow_cursor', {} },
goto_and_close = { '_goto_and_close', {} },
down_and_jump = { '_move_and_jump', { 'down' } },
up_and_jump = { '_move_and_jump', { 'up' } },
fold_toggle = { '_toggle_fold', {} },
fold = { '_set_folded', { true } },
unfold = { '_set_folded', { false } },
fold_toggle_all = { '_toggle_all_fold', {} },
fold_all = { '_set_all_folded', { true } },
unfold_all = { '_set_all_folded', { false } },
fold_reset = { '_set_all_folded', {} },
rename_symbol = {
providers.action, { self, 'rename_symbol', { self } }
},
code_actions = {
providers.action, { self, 'code_actions', { self } }
},
hover_symbol = {
providers.action, { self, 'show_hover', { self } }
},
}) do
---@diagnostic disable-next-line param-type-mismatch
self:nmap(name, meth[1], meth[2])
end
local toggle_preview
if cfg.o.preview_window.auto_preview and cfg.o.preview_window.live then
toggle_preview = { function() self.preview:focus() end, {} }
else
toggle_preview = { function() self.preview:toggle() end, {} }
end
self:nmap('toggle_preview', toggle_preview[1], toggle_preview[2])
end
-- stylua: ignore end
---Autocmds for the (current) outline buffer
function Sidebar:setup_buffer_autocmd()
if cfg.o.preview_window.auto_preview then
vim.api.nvim_create_autocmd('CursorMoved', {
buffer = 0,
callback = function()
self.preview:show()
end,
})
else
vim.api.nvim_create_autocmd('CursorMoved', {
buffer = 0,
callback = function()
self.preview:close()
end,
})
end
if cfg.o.outline_window.auto_jump then
vim.api.nvim_create_autocmd('CursorMoved', {
buffer = 0,
callback = function()
-- Don't use _goto_location because we don't want to auto-close
self:__goto_location(false)
end,
})
end
if cfg.o.outline_window.hide_cursor or type(cfg.o.outline_window.show_cursorline) == 'string' then
-- Unfortunately guicursor is a global option, so we have to make sure to
-- set and unset when cursor leaves the outline window.
self:update_cursor_style()
vim.api.nvim_create_autocmd('BufEnter', {
buffer = 0,
callback = function()
self:update_cursor_style()
end,
})
vim.api.nvim_create_autocmd('BufLeave', {
buffer = 0,
callback = function()
self:reset_cursor_style()
end,
})
end
end
---Setup autocmds for the code buffer that the outline attached to
function Sidebar:setup_attached_buffer_autocmd()
local code_win, code_buf = self.code.win, self.code.buf
local events = cfg.o.outline_items.auto_update_events
if cfg.o.outline_items.highlight_hovered_item or cfg.o.symbol_folding.auto_unfold_hover then
if utils.str_or_nonempty_table(events.follow) then
self.augroup = vim.api.nvim_create_augroup('outline_' .. self.id, { clear = true })
vim.api.nvim_create_autocmd(events.follow, {
group = self.augroup,
buffer = code_buf,
callback = function()
self:_highlight_current_item(code_win, cfg.o.outline_items.auto_set_cursor)
end,
})
end
end
end
---Set hide_cursor depending on whether cursorline is 'focus_in_outline'
function Sidebar:update_cursor_style()
local cl = cfg.o.outline_window.show_cursorline
-- Still 'hide' cursor if show_cursorline set to false, because we've already
-- warned the user during setup.
local hide_cursor = type(cl) ~= 'string'
if cl == 'focus_in_outline' or cl == 'focus_in_code' then
vim.api.nvim_win_set_option(0, 'cursorline', cl == 'focus_in_outline')
hide_cursor = cl == 'focus_in_outline'
end
-- Set cursor color to CursorLine in normal mode
if hide_cursor then
local cur = vim.o.guicursor:match('n.-:(.-)[-,]')
vim.opt.guicursor:append('n:' .. cur .. '-Cursorline')
end
end
function Sidebar:reset_cursor_style()
local cl = cfg.o.outline_window.show_cursorline
if cl == 'focus_in_outline' or cl == 'focus_in_code' then
vim.api.nvim_win_set_option(0, 'cursorline', cl ~= 'focus_in_outline')
end
-- vim.opt doesn't seem to provide a way to remove last item, like a pop()
-- vim.o.guicursor = vim.o.guicursor:gsub(",n.-:.-$", "")
vim.o.guicursor = self.original_cursor
end
---Set the cursor to current.line_in_outline and column to a convenient place
---@param current outline.FlatSymbol?
function Sidebar:update_cursor_pos(current)
local col = 0
local buf = vim.api.nvim_win_get_buf(self.code.win)
if cfg.o.outline_items.show_symbol_lineno then
-- Padding area between lineno column and start of guides
col = #tostring(vim.api.nvim_buf_line_count(buf) - 1)
end
if current then -- Don't attempt to set cursor if the matching node is not found
vim.api.nvim_win_set_cursor(self.view.win, { current.line_in_outline, col })
end
end
---Calls build_outline and then calls update_cursor_pos if update_cursor is
---not false
---@param update_cursor boolean?
---@param set_cursor_to_node outline.Symbol|outline.FlatSymbol?
function Sidebar:_update_lines(update_cursor, set_cursor_to_node)
local current = self:build_outline(set_cursor_to_node)
if update_cursor ~= false then
self:update_cursor_pos(current)
end
end
---@return boolean new_buf
function Sidebar:refresh_setup()
local curwin = vim.api.nvim_get_current_win()
local curbuf = vim.api.nvim_get_current_buf()
local newbuf = curbuf ~= self.code.buf
self.code.win = curwin
self.code.buf = curbuf
self:setup_attached_buffer_autocmd()
return newbuf
end
---Handler for provider request_symbols for refreshing outline
---@param response outline.ProviderSymbol[]
function Sidebar:refresh_handler(response)
if response == nil or type(response) ~= 'table' then
return
end
local newbuf = self:refresh_setup()
local items = parser.parse(response, vim.api.nvim_get_current_buf())
self:_merge_items(items)
local update_cursor = newbuf or cfg.o.outline_items.auto_set_cursor
self:_update_lines(update_cursor)
end
---@param items outline.Symbol[]
function Sidebar:_merge_items(items)
parser.merge_items_rec({ children = items }, { children = self.items })
end
---Re-request symbols from provider
function Sidebar:__refresh()
local buf = vim.api.nvim_get_current_buf()
local focused_outline = self.view.buf == buf
if focused_outline or not self.view:is_open() then
return
end
local ft = vim.api.nvim_buf_get_option(buf, 'ft')
local listed = vim.api.nvim_buf_get_option(buf, 'buflisted')
if ft == 'OutlineHelp' or not listed then
return
end
self.provider = providers.find_provider()
if self.provider then
self.provider.request_symbols(function(res)
self:refresh_handler(res)
end)
return
end
-- No provider
self:refresh_setup()
self:no_providers_ui()
end
-- stylua: ignore start
-- TODO: Is this still needed?
function Sidebar:_refresh()
(utils.debounce(function() self:__refresh() end, 100))()
end
-- stylua: ignore end
function Sidebar:no_providers_ui()
self.view:rewrite_lines({ 'No supported provider...' })
vim.api.nvim_win_set_cursor(self.view.win, { 1, 0 })
end
---Currently hovered node in outline
---@return outline.FlatSymbol
function Sidebar:_current_node()
local current_line = vim.api.nvim_win_get_cursor(self.view.win)[1]
return self.flats[current_line]
end
---@param change_focus boolean Whether to switch to code window after setting cursor
function Sidebar:__goto_location(change_focus)
local node = self:_current_node()
vim.api.nvim_win_set_cursor(self.code.win, { node.line + 1, node.character })
if cfg.o.outline_window.center_on_jump then
vim.fn.win_execute(self.code.win, 'normal! zz')
end
utils.flash_highlight(
self.code.win,
node.line + 1,
cfg.o.outline_window.jump_highlight_duration,
'OutlineJumpHighlight'
)
if change_focus then
vim.fn.win_gotoid(self.code.win)
end
end
---Wraps __goto_location and handles auto_close.
---@see __goto_location
---@param change_focus boolean
function Sidebar:_goto_location(change_focus)
self:__goto_location(change_focus)
if change_focus and cfg.o.outline_window.auto_close then
self:close()
end
end
function Sidebar:_goto_and_close()
self:__goto_location(true)
self:close()
end
---Goto location in code, run fn() then go back to outline.
---Like emacs save-excursion but here it's explicitly goto_location.
---@param fn function
function Sidebar:wrap_goto_location(fn)
local pos = vim.api.nvim_win_get_cursor(0)
self:__goto_location(true)
fn()
vim.fn.win_gotoid(self.view.win)
vim.api.nvim_win_set_cursor(self.view.win, pos)
end
---@param direction "up"|"down"
function Sidebar:_move_and_jump(direction)
local move = direction == 'down' and 1 or -1
local cur = vim.api.nvim_win_get_cursor(0)
cur[1] = cur[1] + move
pcall(vim.api.nvim_win_set_cursor, 0, cur)
self:__goto_location(false)
end
---@param move_cursor boolean
---@param node_index integer Index for self.flats
function Sidebar:_toggle_fold(move_cursor, node_index)
local node = self.flats[node_index] or self:_current_node()
local is_folded = folding.is_folded(node)
if folding.is_foldable(node) then
self:_set_folded(not is_folded, move_cursor, node_index)
end
end
---@param folded boolean
---@param move_cursor? boolean
---@param node_index? integer
function Sidebar:_set_folded(folded, move_cursor, node_index)
local node = self.flats[node_index] or self:_current_node()
local changed = (folded ~= folding.is_folded(node))
if folding.is_foldable(node) and changed then
node.folded = folded
if move_cursor then
vim.api.nvim_win_set_cursor(self.view.win, { node_index, 0 })
end
self:_update_lines(false)
elseif node.parent then
local parent_node = self.flats[node.parent.line_in_outline]
if parent_node then
self:_set_folded(folded, not parent_node.folded and folded, parent_node.line_in_outline)
end
end
end
---@param nodes outline.Symbol[]
function Sidebar:_toggle_all_fold(nodes)
nodes = nodes or self.items
local folded = true
for _, node in ipairs(nodes) do
if folding.is_foldable(node) and not folding.is_folded(node) then
folded = false
break
end
end
self:_set_all_folded(not folded, nodes)
end
---@param folded boolean?
---@param nodes? outline.Symbol[]
function Sidebar:_set_all_folded(folded, nodes)
local stack = { nodes or self.items }
local current = self:_current_node()
while #stack > 0 do
local current_nodes = table.remove(stack, #stack)
for _, node in ipairs(current_nodes) do
node.folded = folded
if node.children then
stack[#stack + 1] = node.children
end
end
end
self:_update_lines(true, current)
end
function Sidebar:has_code_win()
return self.code.win
and self.code.buf
and self.code.win ~= 0
and self.code.buf ~= 0
and vim.api.nvim_win_is_valid(self.code.win)
and vim.api.nvim_buf_is_valid(self.code.buf)
end
---@see outline.follow_cursor
---@param opts outline.OutlineOpts?
---@return boolean ok
function Sidebar:follow_cursor(opts)
if not self.view:is_open() then
return false
end
if self:has_code_win() then
self:_highlight_current_item(self.code.win, true)
else
return false
end
if not opts then
opts = { focus_outline = true }
end
if opts.focus_outline then
self:focus()
end
return true
end
function Sidebar:_map_follow_cursor()
if not self:follow_cursor({ focus_outline = true }) then
utils.echo('Code window no longer active. Try closing and reopening the outline.')
end
end
---@param opts outline.OutlineOpts?
---@return boolean is_open
function Sidebar:toggle(opts)
if self.view:is_open() then
self:close()
return false
else
self:open(opts)
return true
end
end
---@see outline.open_outline
---@param opts outline.OutlineOpts?
function Sidebar:open(opts)
if not opts then
opts = { focus_outline = true }
end
if not self.view:is_open() then
self.preview.s = self
self.provider = providers.find_provider()
if self.provider then
self.provider.request_symbols(function(...)
self:initial_handler(...)
end, opts)
return
else
-- No provider
self:initial_setup(opts)
self:no_providers_ui()
end
if not cfg.o.outline_window.focus_on_open or not opts.focus_outline then
vim.fn.win_gotoid(self.code.win)
end
end
end
---@see outline.close_outline
function Sidebar:close()
local code_win = self.code.win
self.view:close()
self.preview:close()
vim.fn.win_gotoid(code_win)
end
---@see outline.focus_outline
---@return boolean is_open
function Sidebar:focus()
if self.view:is_open() then
vim.fn.win_gotoid(self.view.win)
return true
end
return false
end
---@see outline.focus_code
---@return boolean ok
function Sidebar:focus_code()
if self:has_code_win() then
vim.fn.win_gotoid(self.code.win)
return true
end
return false
end
---@see outline.focus_toggle
---@return boolean ok
function Sidebar:focus_toggle()
if self.view:is_open() and self:has_code_win() then
local winid = vim.fn.win_getid()
if winid == self.code.win then
vim.fn.win_gotoid(self.view.win)
else
vim.fn.win_gotoid(self.code.win)
end
return true
end
return false
end
---Whether the outline window is currently open.
---@return boolean is_open
function Sidebar:is_open()
return self.view:is_open()
end
function Sidebar:has_focus()
local winid = vim.fn.win_getid()
return self.view:is_open() and winid == self.view.win
end
---Whether there is currently an available provider.
---@return boolean has_provider
function Sidebar:has_provider()
if self:has_focus() then
return self.provider ~= nil
end
return providers.has_provider()
end
function Sidebar:_highlight_current_item(winnr, update_cursor)
local has_provider = self:has_provider()
local has_outline_open = self.view:is_open()
local current_buffer_is_outline = self.view.buf == vim.api.nvim_get_current_buf()
if not has_provider then
return
end
if current_buffer_is_outline and not winnr then
-- Don't update cursor pos and content if they are navigating the outline.
-- Winnr may be given when user explicitly wants to restore location
-- (follow_cursor), or through the open handler.
return
end
if not has_outline_open and not winnr then
-- Outline not open and no code window given
return
end
local valid_code_win = vim.api.nvim_win_is_valid(self.code.win)
local valid_winnr = winnr and vim.api.nvim_win_is_valid(winnr)
if not valid_code_win then
-- Definetely don't attempt to update anything if code win is no longer valid
return
end
if not valid_winnr then
return
elseif winnr ~= self.code.win then
-- Both valid, but given winnr ~= known code win.
-- Best not to handle this situation at all to prevent any unwanted side
-- effects
return
end
self:_update_lines(update_cursor)
end
---The quintessential function of this entire plugin. Clears virtual text,
---parses each node and replaces old lines with new lines to be written for the
---outline buffer.
---
---Handles highlights, virtual text, and of course lines of outline to write.
---@note Ensure new outlines are already set to `self.items` before calling
---this function. `self.flats` will be overwritten and current line is obtained
---from `win_get_cursor` using `self.code.win`.
---@param find_node outline.FlatSymbol|outline.Symbol? Find a given node rather than node matching cursor position in codewin
---@return outline.FlatSymbol? set_cursor_to_this_node
function Sidebar:build_outline(find_node)
---@type integer 0-indexed
local hovered_line = vim.api.nvim_win_get_cursor(self.code.win)[1] - 1
---@type outline.FlatSymbol Deepest visible matching node to set cursor
local put_cursor
self.flats = {}
local line_count = 0
local lines = {} ---@type string[]
local details = {} ---@type string[]
local linenos = {} ---@type string[]
local hl = {} ---@type outline.HL[]
-- Find the prefix for each line needed for the lineno space.
-- Use [max width of [max_line-1]] + 1 space padding.
-- -1 because if max_width is a power of ten, don't shift the entire lineno
-- column by the right just because the last line number requires an extra
-- digit. i.e.: If max_width is 1000, the lineno column will take up 3
-- columns to fill the digits, and 1 padding on the right. The 1000 can fit
-- perfectly there.
local lineno_offset = 0
local lineno_prefix = ''
local lineno_max_width = #tostring(vim.api.nvim_buf_line_count(self.code.buf) - 1)
if cfg.o.outline_items.show_symbol_lineno then
lineno_offset = math.max(2, lineno_max_width) + 1
lineno_prefix = string.rep(' ', lineno_offset)
end
-- Closures for convenience
-- stylua: ignore start
local function save_guide_hl(from, to)
table.insert(hl, {
line = line_count, name = 'OutlineGuides',
from = from, to = to,
})
end
local function save_fold_hl(from, to)
table.insert(hl, {
line = line_count, name = 'OutlineFoldMarker',
from = from, to = to,
})
end
-- stylua: ignore end
local guide_markers = cfg.o.guides.markers
local fold_markers = cfg.o.symbol_folding.markers
for node in parser.preorder_iter(self.items) do
line_count = line_count + 1
node.line_in_outline = line_count
table.insert(self.flats, node)
node.hovered = false
if
node.line == hovered_line
or (hovered_line >= node.range_start and hovered_line <= node.range_end)
then
-- Not setting for children, but it works because when unfold is called
-- this function is called again anyway.
node.hovered = true
table.insert(self.hovered, node)
if not find_node then
put_cursor = node
end
end
if find_node and find_node == node then
---@diagnostic disable-next-line: cast-local-type
put_cursor = find_node
end
table.insert(details, node.detail or '')
local lineno = tostring(node.range_start + 1)
local leftpad = string.rep(' ', lineno_max_width - #lineno)
table.insert(linenos, leftpad .. lineno)
-- Make the guides for the line prefix
local pref = utils.str_to_table(string.rep(' ', node.depth))
local fold_marker_width = 0
if folding.is_foldable(node) then
-- Add fold marker
local marker = fold_markers[2]
if folding.is_folded(node) then
marker = fold_markers[1]
end
pref[#pref] = marker
fold_marker_width = strlen(marker)
else
-- Rightmost guide for the direct parent, only added if fold marker is
-- not added
if node.depth > 1 then
local marker = guide_markers.middle
if node.isLast then
marker = guide_markers.bottom
end
pref[#pref] = marker
end
end
-- Add vertical guides to the left, for all parents that isn't the last
-- sibling. Iter from first grandparent until second last ancestor (last
-- ancestor is the entire outline itself, it should not have a vertical
-- guide).
local iternode = node
for i = node.depth - 1, 2, -1 do
iternode = iternode.parent_node
if not iternode.isLast then
pref[i] = guide_markers.vertical
end
end
-- Finished with guide prefix. Now join all prefix chars by a space
local pref_str = table.concat(pref, ' ')
local total_pref_len = lineno_offset + #pref_str
-- Guide hl goes from start of prefix till before the fold marker, if any.
-- Fold hl goes from start of fold marker until before the icon.
save_guide_hl(lineno_offset, total_pref_len - fold_marker_width)
if fold_marker_width > 0 then
save_fold_hl(total_pref_len - fold_marker_width, total_pref_len + 1)
end
local line = lineno_prefix .. pref_str
local icon_pref = 0
if node.icon ~= '' then
line = line .. ' ' .. node.icon
icon_pref = 1
end
line = line .. ' ' .. node.name
-- Start from left of icon col
local hl_start = #pref_str + #lineno_prefix + icon_pref
local hl_end = hl_start + #node.icon -- until after icon
local hl_type = cfg.o.symbols.icons[symbols.kinds[node.kind]].hl
-- stylua: ignore start
table.insert(hl, {
line = line_count, name = hl_type,
from = hl_start, to = hl_end,
})
-- stylua: ignore end
-- Prefix length is from start until the beginning of the node.name, used
-- for hover highlights.
node.prefix_length = hl_end + 1
-- Each line passed to nvim_buf_set_lines cannot contain newlines
line = line:gsub('\n', ' ')
table.insert(lines, line)
end
-- PERF:
-- * Is setting individual lines is not as good as rewriting entire buffer?
-- That way we can set all highlights and virtual text together without
-- requiring extra O(n) iterations.
-- * Is there a significant difference if new lines are set first, on top
-- of old highlights, before resetting the highlights? (Rather than doing
-- like below)
self.view:clear_all_ns()
self.view:rewrite_lines(lines)
self.view:add_hl_and_ns(hl, self.flats, details, linenos)
return put_cursor
end
return Sidebar