diff --git a/CHANGELOG.md b/CHANGELOG.md index da19682..10eaea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ This ensures outline.nvim highlights work if `termguicolors` is not enabled - A built-in provider for `norg` files that displays headings in the outline is now provided. This requires `norg` parser to be installed for treesitter +- Outline.nvim now supports per-tabpage outlines ### Fixes diff --git a/lua/outline/code_action.lua b/lua/outline/code_action.lua index 4f27aed..f6910d1 100644 --- a/lua/outline/code_action.lua +++ b/lua/outline/code_action.lua @@ -1,11 +1,11 @@ -local main = require('outline') +local outline = require('outline') local M = {} function M.show_code_actions() -- keep the cursor info in outline and jump back (or not jump back?) local winnr, pos = vim.api.nvim_get_current_win(), vim.api.nvim_win_get_cursor(0) - main._goto_location(true) + outline.current:_goto_location(true) vim.lsp.buf.code_action() vim.fn.win_gotoid(winnr) vim.api.nvim_win_set_cursor(winnr, pos) diff --git a/lua/outline/config.lua b/lua/outline/config.lua index 5c703c8..881459a 100644 --- a/lua/outline/config.lua +++ b/lua/outline/config.lua @@ -26,7 +26,7 @@ M.defaults = { auto_set_cursor = true, auto_update_events = { follow = { 'CursorMoved' }, - items = { 'InsertLeave', 'WinEnter', 'BufEnter', 'BufWinEnter', 'TabEnter', 'BufWritePost' }, + items = { 'InsertLeave', 'WinEnter', 'BufEnter', 'BufWinEnter', 'BufWritePost' }, }, }, outline_window = { diff --git a/lua/outline/hover.lua b/lua/outline/hover.lua index 5183142..9295452 100644 --- a/lua/outline/hover.lua +++ b/lua/outline/hover.lua @@ -17,10 +17,10 @@ end -- handler yoinked from the default implementation function M.show_hover() - local current_line = vim.api.nvim_win_get_cursor(outline.view.winnr)[1] - local node = outline.state.flattened_outline_items[current_line] + local current_line = vim.api.nvim_win_get_cursor(outline.current.view.winnr)[1] + local node = outline.current.flats[current_line] - local hover_params = get_hover_params(node, outline.state.code_win) + local hover_params = get_hover_params(node, outline.current.code.win) vim.lsp.buf_request( hover_params.bufnr, diff --git a/lua/outline/init.lua b/lua/outline/init.lua index 5dab3ca..d775147 100644 --- a/lua/outline/init.lua +++ b/lua/outline/init.lua @@ -1,465 +1,103 @@ -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 Sidebar = require('outline.sidebar') local ui = require('outline.ui') local utils = require('outline.utils.init') -local writer = require('outline.writer') -local M = {} +local M = { + ---@type outline.Sidebar[] + sidebars = {}, + ---@type outline.Sidebar + current = nil, +} local function setup_global_autocmd() if utils.table_has_content(cfg.o.outline_items.auto_update_events.items) then vim.api.nvim_create_autocmd(cfg.o.outline_items.auto_update_events.items, { pattern = '*', - callback = M._refresh, + callback = function() M._sidebar_do('_refresh') end, }) end vim.api.nvim_create_autocmd('WinEnter', { pattern = '*', callback = require('outline.preview').close, }) -end - -------------------------- --- STATE -------------------------- -M.state = { - opened_first_outline = false, - ---@type outline.SymbolNode[] - outline_items = {}, - ---@type outline.FlatSymbolNode[] - flattened_outline_items = {}, - code_win = 0, - code_buf = 0, - autocmds = {}, - -- In case unhide_cursor was called before hide_cursor for _some_ reason, - -- this can still be used as a fallback - original_cursor = vim.o.guicursor, -} - -local function wipe_state() - for _, code_win in ipairs(M.state.autocmds) do - if vim.api.nvim_win_is_valid(code_win) and M.state.autocmds[code_win] then - vim.api.nvim_del_autocmd(M.state.autocmds[code_win]) - end - end - - M.state = { - outline_items = {}, - flattened_outline_items = {}, - code_win = 0, - code_buf = 0, - autocmds = {}, - opts = {}, - } -end - ----Calls writer.make_outline and then calls M.update_cursor_pos if update_cursor is not false ----@param update_cursor boolean? ----@param set_cursor_to_node outline.SymbolNode|outline.FlatSymbolNode? -local function _update_lines(update_cursor, set_cursor_to_node) - local current - M.state.flattened_outline_items, current = - writer.make_outline(M.view.bufnr, M.state.outline_items, M.state.code_win, set_cursor_to_node) - if update_cursor ~= false then - M.update_cursor_pos(current) - end -end - ----@param items outline.SymbolNode[] -local function _merge_items(items) - utils.merge_items_rec({ children = items }, { children = M.state.outline_items }) -end - ----Setup autocmds for the buffer that the outline attached to ----@param code_win integer Must be valid ----@param code_buf integer Must be valid -local function setup_attached_buffer_autocmd(code_win, 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 M.state.autocmds[code_win] then - vim.api.nvim_del_autocmd(M.state.autocmds[code_win]) - M.state.autocmds[code_win] = nil - end - - if utils.str_or_nonempty_table(events.follow) then - M.state.autocmds[code_win] = vim.api.nvim_create_autocmd(events.follow, { - buffer = code_buf, - callback = function() - M._highlight_current_item(code_win, cfg.o.outline_items.auto_set_cursor) - end, - }) - end - end -end - -local function __refresh() - local current_buffer_is_outline = M.view.bufnr == vim.api.nvim_get_current_buf() - if M.view:is_open() and not current_buffer_is_outline then - local function refresh_handler(response) - if response == nil or type(response) ~= 'table' then - return + vim.api.nvim_create_autocmd('TabClosed', { + pattern = '*', + callback = function(o) + local tab = tonumber(o.file) + local s = M.sidebars[tab] + if s then + s:destroy() end - - local curwin = vim.api.nvim_get_current_win() - local curbuf = vim.api.nvim_get_current_buf() - local newbuf = curbuf ~= M.state.code_buf - - if M.state.code_win ~= curwin then - if M.state.autocmds[M.state.code_win] then - vim.api.nvim_del_autocmd(M.state.autocmds[M.state.code_win]) - M.state.autocmds[M.state.code_win] = nil - end - end - - M.state.code_win = curwin - M.state.code_buf = curbuf - - setup_attached_buffer_autocmd(curwin, curbuf) - - local items = parser.parse(response, vim.api.nvim_get_current_buf()) - _merge_items(items) - - local update_cursor = newbuf or cfg.o.outline_items.auto_set_cursor - _update_lines(update_cursor) - end - - providers.request_symbols(refresh_handler) - end -end - -M._refresh = utils.debounce(__refresh, 100) - ----@return outline.FlatSymbolNode -function M._current_node() - local current_line = vim.api.nvim_win_get_cursor(M.view.winnr)[1] - return M.state.flattened_outline_items[current_line] -end - ----@param change_focus boolean -function M.__goto_location(change_focus) - local node = M._current_node() - vim.api.nvim_win_set_cursor(M.state.code_win, { node.line + 1, node.character }) - if cfg.o.outline_window.center_on_jump then - vim.fn.win_execute(M.state.code_win, 'normal! zz') - end - - utils.flash_highlight( - M.state.code_win, - node.line + 1, - cfg.o.outline_window.jump_highlight_duration, - 'OutlineJumpHighlight' - ) - - if change_focus then - vim.fn.win_gotoid(M.state.code_win) - end -end - ----Wraps __goto_location and handles auto_close. ----@see __goto_location ----@param change_focus boolean -function M._goto_location(change_focus) - M.__goto_location(change_focus) - if change_focus and cfg.o.outline_window.auto_close then - M.close_outline() - end -end - -function M._goto_and_close() - M.__goto_location(true) - M.close_outline() -end - ----@param direction "up"|"down" -function M._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) - M.__goto_location(false) -end - ----@param move_cursor boolean ----@param node_index integer Index for M.state.flattened_outline_items -function M._toggle_fold(move_cursor, node_index) - local node = M.state.flattened_outline_items[node_index] or M._current_node() - local is_folded = folding.is_folded(node) - - if folding.is_foldable(node) then - M._set_folded(not is_folded, move_cursor, node_index) - end -end - -local function update_cursor_style() - local cl = cfg.o.outline_window.show_cursorline - -- XXX: 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 - M.state.original_cursor = vim.o.guicursor - local cur = vim.o.guicursor:match('n.-:(.-)[-,]') - vim.opt.guicursor:append('n:' .. cur .. '-Cursorline') - end -end - -local function 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 = M.state.original_cursor -end - ----Autocmds for the (current) outline buffer -local function setup_buffer_autocmd() - if cfg.o.preview_window.auto_preview then - vim.api.nvim_create_autocmd('CursorMoved', { - buffer = 0, - callback = require('outline.preview').show, - }) - else - vim.api.nvim_create_autocmd('CursorMoved', { - buffer = 0, - callback = require('outline.preview').close, - }) - 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 - M.__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. - update_cursor_style() - vim.api.nvim_create_autocmd('BufEnter', { - buffer = 0, - callback = update_cursor_style, - }) - vim.api.nvim_create_autocmd('BufLeave', { - buffer = 0, - callback = reset_cursor_style, - }) - end -end - ----@param folded boolean ----@param move_cursor? boolean ----@param node_index? integer -function M._set_folded(folded, move_cursor, node_index) - local node = M.state.flattened_outline_items[node_index] or M._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(M.view.winnr, { node_index, 0 }) - end - - _update_lines(false) - elseif node.parent then - local parent_node = M.state.flattened_outline_items[node.parent.line_in_outline] - - if parent_node then - M._set_folded(folded, not parent_node.folded and folded, parent_node.line_in_outline) - end - end -end - ----@param nodes outline.SymbolNode[] -function M._toggle_all_fold(nodes) - nodes = nodes or M.state.outline_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 - - M._set_all_folded(not folded, nodes) -end - ----@param folded boolean|nil ----@param nodes? outline.SymbolNode[] -function M._set_all_folded(folded, nodes) - local stack = { nodes or M.state.outline_items } - local current = M._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 - - _update_lines(true, current) -end - ----@param winnr? integer Window number of code window ----@param update_cursor? boolean -function M._highlight_current_item(winnr, update_cursor) - local has_provider = M.has_provider() - local has_outline_open = M.view:is_open() - local current_buffer_is_outline = M.view.bufnr == 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(M.state.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 ~= M.state.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 - - _update_lines(update_cursor) -end - -local function setup_keymaps(bufnr) - local map = function(...) - utils.nmap(bufnr, ...) - end - map(cfg.o.keymaps.goto_location, function() - M._goto_location(true) - end) - map(cfg.o.keymaps.peek_location, function() - M._goto_location(false) - end) - map(cfg.o.keymaps.restore_location, M._map_follow_cursor) - map(cfg.o.keymaps.goto_and_close, M._goto_and_close) - map(cfg.o.keymaps.down_and_jump, function() - M._move_and_jump('down') - end) - map(cfg.o.keymaps.up_and_jump, function() - M._move_and_jump('up') - end) - map(cfg.o.keymaps.hover_symbol, require('outline.hover').show_hover) - map(cfg.o.keymaps.toggle_preview, require('outline.preview').toggle) - map(cfg.o.keymaps.rename_symbol, require('outline.rename').rename) - map(cfg.o.keymaps.code_actions, require('outline.code_action').show_code_actions) - map(cfg.o.keymaps.show_help, require('outline.docs').show_help) - map(cfg.o.keymaps.close, function() - M.view:close() - end) - map(cfg.o.keymaps.fold_toggle, M._toggle_fold) - map(cfg.o.keymaps.fold, function() - M._set_folded(true) - end) - map(cfg.o.keymaps.unfold, function() - M._set_folded(false) - end) - map(cfg.o.keymaps.fold_toggle_all, M._toggle_all_fold) - map(cfg.o.keymaps.fold_all, function() - M._set_all_folded(true) - end) - map(cfg.o.keymaps.unfold_all, function() - M._set_all_folded(false) - end) - map(cfg.o.keymaps.fold_reset, function() - M._set_all_folded(nil) - end) -end - ----@param current outline.FlatSymbolNode? -function M.update_cursor_pos(current) - local col = 0 - local buf = vim.api.nvim_win_get_buf(M.state.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(M.view.winnr, { current.line_in_outline, col }) - end -end - ----@param response table? ----@param opts outline.OutlineOpts? -local function handler(response, opts) - if response == nil or type(response) ~= 'table' or M.view:is_open() then - return - end - - if not opts then - opts = {} - end - - M.state.code_win = vim.api.nvim_get_current_win() - M.state.code_buf = vim.api.nvim_get_current_buf() - M.state.opened_first_outline = true - - local sc = opts.split_command or cfg.get_split_command() - M.view:setup_view(sc) - - -- clear state when buffer is closed - vim.api.nvim_buf_attach(M.view.bufnr, false, { - on_detach = function(_, _) - wipe_state() + M.sidebars[tab] = nil end, }) +end - setup_keymaps(M.view.bufnr) - setup_buffer_autocmd() - setup_attached_buffer_autocmd(M.state.code_win, M.state.code_buf) - - local items = parser.parse(response, M.state.code_buf) - - M.state.outline_items = items - local current - M.state.flattened_outline_items, current = - writer.make_outline(M.view.bufnr, items, M.state.code_win) - - M.update_cursor_pos(current) - - if not cfg.o.outline_window.focus_on_open or not opts.focus_outline then - vim.fn.win_gotoid(M.state.code_win) +---Obtain the sidebar object for current tabpage +---@param set_current boolean? Set to false to disable setting M.current +---@return outline.Sidebar? +function M._get_sidebar(set_current) + local tab = vim.api.nvim_get_current_tabpage() + local sidebar = M.sidebars[tab] + if set_current ~= false then + M.current = sidebar end + return sidebar +end + +---Run a Sidebar method by getting the sidebar of current tabpage, with args +-- NOP if sidebar not found for this tabpage. +---@param method string Must be valid +---@param args table? +---@return any return_of_method Depends on sidebar `method` +function M._sidebar_do(method, args) + local sidebar = M._get_sidebar() + if not sidebar then + return + end + + args = args or {} + return sidebar[method](sidebar, unpack(args)) +end + +---Close the current outline window +function M.close_outline() + return M._sidebar_do('close') +end + +---Toggle the outline window, and return whether the outline window is open +-- after this operation. +---@see open_outline +---@param opts outline.OutlineOpts? Table of options +---@return boolean is_open Whether outline window is now open +function M.toggle_outline(opts) + local sidebar = M._get_sidebar() + if not sidebar then + M.open_outline(opts) + return true + end + return sidebar:toggle(opts) +end + +---Set cursor to focus on the outline window, return whether the window is +-- currently open. +---@return boolean is_open Whether the window is open +function M.focus_outline() + return M._sidebar_do('focus') +end + +---Set cursor to focus on the code window, return whether this operation was successful. +---@return boolean ok Whether it was successful. If unsuccessful, it might mean that the attached code window has been closed or is no longer valid. +function M.focus_code() + return M._sidebar_do('focus_code') +end + +---Toggle focus between outline and code window, returns whether it was successful. +---@return boolean ok Whether it was successful. If `ok=false`, either the outline window is not open or the code window is no longer valid. +function M.focus_toggle() + return M._sidebar_do('focus_toggle') end ---Set position of outline window to match cursor position in code, return @@ -467,55 +105,35 @@ end ---@param opts outline.OutlineOpts? Field `focus_outline` = `false` or `nil` means don't focus on outline window after following cursor. If opts is not provided, focus will be on outline window after following cursor. ---@return boolean ok Whether it was successful. If ok=false, either the outline window is not open or the code window cannot be found. function M.follow_cursor(opts) - if not M.view:is_open() then - return false - end - - if require('outline.preview').has_code_win() then - M._highlight_current_item(M.state.code_win, true) - else - return false - end - - if not opts then - opts = { focus_outline = true } - end - if opts.focus_outline then - M.focus_outline() - end - - return true + return M._sidebar_do('follow_cursor', { opts }) end +---Trigger re-requesting of symbols from provider +function M.refresh_outline() + return M._sidebar_do('__refresh') +end + +---Open the outline window. +---@param opts outline.OutlineOpts? Field focus_outline=false means don't focus on outline window after opening. If opts is not provided, focus will be on outline window after opening. +function M.open_outline(opts) + local tab = vim.api.nvim_get_current_tabpage() + local sidebar = M.sidebars[tab] + M.current = sidebar + + if not sidebar then + sidebar = Sidebar:new() + M.sidebars[tab] = sidebar + end + + return sidebar:open(opts) +end + +---Handle follow cursor command with bang local function _cmd_follow_cursor(opts) - local fnopts = { focus_outline = true } - if opts.bang then - fnopts.focus_outline = false - end - M.follow_cursor(fnopts) -end - -function M._map_follow_cursor() - if not M.follow_cursor({ focus_outline = true }) then - utils.echo('Code window no longer active. Try closing and reopening the outline.') - end -end - ----Toggle the outline window, and return whether the outline window is open ----after this operation. ----@see open_outline ----@param opts outline.OutlineOpts? Table of options ----@return boolean is_open Whether outline window is open -function M.toggle_outline(opts) - if M.view:is_open() then - M.close_outline() - return false - else - M.open_outline(opts) - return true - end + M.follow_cursor({ focus_outline = not opts.bang }) end +---Handle open/toggle command with mods and bang local function _cmd_open_with_mods(fn) return function(opts) local fnopts = { focus_outline = not opts.bang } @@ -530,106 +148,40 @@ local function _cmd_open_with_mods(fn) end end ----Open the outline window. ----@param opts outline.OutlineOpts? Field focus_outline=false means don't focus on outline window after opening. If opts is not provided, focus will be on outline window after opening. -function M.open_outline(opts) - if not opts then - opts = { focus_outline = true } - end - if not M.view:is_open() then - local found = providers.request_symbols(handler, opts) - if not found then - utils.echo('No providers found for current buffer') - end - end -end - ----Close the outline window. -function M.close_outline() - M.view:close() -end - ----Set cursor to focus on the outline window, return whether the window is currently open.. ----@return boolean is_open Whether the window is open -function M.focus_outline() - if M.view:is_open() then - vim.fn.win_gotoid(M.view.winnr) - return true - end - return false -end - ----Set cursor to focus on the code window, return whether this operation was successful. ----@return boolean ok Whether it was successful. If unsuccessful, it might mean that the attached code window has been closed or is no longer valid. -function M.focus_code() - if require('outline.preview').has_code_win() then - vim.fn.win_gotoid(M.state.code_win) - return true - end - return false -end - ----Toggle focus between outline and code window, returns whether it was successful. ----@return boolean ok Whether it was successful. If `ok=false`, either the outline window is not open or the code window is no longer valid. -function M.focus_toggle() - if M.view:is_open() and require('outline.preview').has_code_win() then - local winid = vim.fn.win_getid() - if winid == M.state.code_win then - vim.fn.win_gotoid(M.view.winnr) - else - vim.fn.win_gotoid(M.state.code_win) - end - return true - end - return false -end - ----Whether the outline window is currently open. ----@return boolean is_open -function M.is_open() - return M.view:is_open() -end - -function M.is_focus_in_outline() - local winid = vim.fn.win_getid() - if M.view:is_open() and winid == M.view.winnr then - return true - end - return false -end - ----Whether there is currently an available provider. ----@return boolean has_provider -function M.has_provider() - if M.is_focus_in_outline() then - return _G._outline_current_provider ~= nil - end - return providers.has_provider() -end - +---Open a floating window displaying debug information about outline function M.show_status() + local sidebar = M._get_sidebar(false) + local buf, win = 0, 0 + local is_open + + if sidebar then + buf = sidebar.code.buf + win = sidebar.code.win + is_open = sidebar.view:is_open() + end + ---@type outline.StatusContext local ctx = { priority = cfg.o.providers.priority } - if vim.api.nvim_buf_is_valid(M.state.code_buf) then - ctx.ft = vim.api.nvim_buf_get_option(M.state.code_buf, 'ft') + if vim.api.nvim_buf_is_valid(buf) then + ctx.ft = vim.api.nvim_buf_get_option(buf, 'ft') end ctx.filter = cfg.o.symbols.user_config_filter[ctx.ft] ctx.default_filter = cfg.o.symbols.user_config_filter.default local p = _G._outline_current_provider - if not M.view or not M.view:is_open() then + if not is_open then p = providers.find_provider() end if p ~= nil then ctx.provider = p ctx.outline_open = false - if M.view and M.view:is_open() then + if is_open then ctx.outline_open = true end ctx.code_win_active = false - if require('outline.preview').has_code_win() then + if require('outline.preview').has_code_win(win) then ctx.code_win_active = true end end @@ -637,11 +189,6 @@ function M.show_status() return require('outline.docs').show_status(ctx) end ----Re-request symbols from the provider and update the outline accordingly -function M.refresh_outline() - return __refresh() -end - local function setup_commands() local cmd = function(n, c, o) vim.api.nvim_create_user_command('Outline' .. n, c, o) @@ -672,7 +219,7 @@ With bang, don't switch cursor focus to outline window.", nargs = 0, bang = true, }) - cmd('Refresh', __refresh, { + cmd('Refresh', M.refresh_outline, { desc = 'Trigger manual outline refresh of items.', nargs = 0, }) @@ -695,7 +242,6 @@ function M.setup(opts) cfg.setup(opts) ui.setup_highlights() - M.view = View:new() setup_global_autocmd() setup_commands() end diff --git a/lua/outline/preview.lua b/lua/outline/preview.lua index 1a499ad..07fef49 100644 --- a/lua/outline/preview.lua +++ b/lua/outline/preview.lua @@ -11,17 +11,16 @@ local state = { local function is_current_win_outline() local curwin = vim.api.nvim_get_current_win() - return curwin == outline.view.winnr + return curwin == outline.current.view.winnr end -local function has_code_win() - local isWinValid = vim.api.nvim_win_is_valid(outline.state.code_win) - if not isWinValid then +local function has_code_win(winnr) + if not outline.current then return false end - local bufnr = vim.api.nvim_win_get_buf(outline.state.code_win) - local isBufValid = vim.api.nvim_buf_is_valid(bufnr) - return isBufValid + winnr = winnr or outline.current.code.win + return vim.api.nvim_win_is_valid(winnr) + and vim.api.nvim_buf_is_valid(outline.current.code.buf) end M.has_code_win = has_code_win @@ -31,10 +30,10 @@ M.has_code_win = has_code_win ---@param preview_width integer local function get_col(preview_width) ---@type integer - local outline_winnr = outline.view.winnr + local outline_winnr = outline.current.view.winnr local outline_col = vim.api.nvim_win_get_position(outline_winnr)[2] local outline_width = vim.api.nvim_win_get_width(outline_winnr) - local code_col = vim.api.nvim_win_get_position(outline.state.code_win)[2] + local code_col = vim.api.nvim_win_get_position(outline.current.code.win)[2] -- TODO: What if code win is below/above outline instead? @@ -52,21 +51,21 @@ end ---@param outline_height integer local function get_row(preview_height, outline_height) local offset = math.floor((outline_height - preview_height) / 2) - 1 - return vim.api.nvim_win_get_position(outline.view.winnr)[1] + offset + return vim.api.nvim_win_get_position(outline.current.view.winnr)[1] + offset end local function get_height() - return vim.api.nvim_win_get_height(outline.view.winnr) + return vim.api.nvim_win_get_height(outline.current.view.winnr) end local function get_hovered_node() - local hovered_line = vim.api.nvim_win_get_cursor(outline.view.winnr)[1] - local node = outline.state.flattened_outline_items[hovered_line] + local hovered_line = vim.api.nvim_win_get_cursor(outline.current.view.winnr)[1] + local node = outline.current.flats[hovered_line] return node end local function update_preview(code_buf) - code_buf = code_buf or vim.api.nvim_win_get_buf(outline.state.code_win) + code_buf = code_buf or outline.current.code.buf local node = get_hovered_node() if not node then @@ -81,7 +80,7 @@ local function update_preview(code_buf) end local function setup_preview_buf() - local code_buf = vim.api.nvim_win_get_buf(outline.state.code_win) + local code_buf = outline.current.code.buf local ft = vim.api.nvim_buf_get_option(code_buf, 'filetype') vim.api.nvim_buf_set_option(state.preview_buf, 'syntax', ft) diff --git a/lua/outline/rename.lua b/lua/outline/rename.lua index 4a02fe7..55ca234 100644 --- a/lua/outline/rename.lua +++ b/lua/outline/rename.lua @@ -14,10 +14,10 @@ local function get_rename_params(node, winnr) end function M.rename() - local current_line = vim.api.nvim_win_get_cursor(outline.view.winnr)[1] - local node = outline.state.flattened_outline_items[current_line] + local current_line = vim.api.nvim_win_get_cursor(outline.current.view.winnr)[1] + local node = outline.current.flats[current_line] - local params = get_rename_params(node, outline.state.code_win) + local params = get_rename_params(node, outline.current.code.win) local new_name = vim.fn.input({ prompt = 'New Name: ', default = node.name }) if not new_name or new_name == '' or new_name == node.name then diff --git a/lua/outline/sidebar.lua b/lua/outline/sidebar.lua new file mode 100644 index 0000000..0b0ac0b --- /dev/null +++ b/lua/outline/sidebar.lua @@ -0,0 +1,606 @@ +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 utils = require('outline.utils.init') +local writer = require('outline.writer') + +---@class outline.Sidebar +local Sidebar = {} + +---@class outline.SidebarCodeState +---@field win integer +---@field buf integer + +---@class outline.Sidebar +---@field view outline.View +---@field items outline.SymbolNode[] +---@field flats outline.FlatSymbolNode[] +---@field original_cursor string +---@field code outline.SidebarCodeState +---@field autocmds { [integer]: integer } winnr to autocmd id + +function Sidebar:new() + return setmetatable({ + view = View:new(), + code = { buf = 0, win = 0 }, + items = {}, + flats = {}, + autocmds = {}, + original_cursor = vim.o.guicursor, + }, { __index = Sidebar }) +end + +function Sidebar:delete_autocmds() + for codewin, au in pairs(self.autocmds) do + if vim.api.nvim_win_is_valid(codewin) then + vim.api.nvim_del_autocmd(au) + end + end + self.autocmds = {} +end + +function Sidebar:reset_state() + self.code = { buf = 0, win = 0 } + self.items = {} + self.flats = {} + self.original_cursor = vim.o.guicursor + self:delete_autocmds() +end + +function Sidebar:destroy() + self:delete_autocmds() + if self.view:is_open() then + vim.print('closing') + self.view:close() + end + self.view = nil + self.items = nil + self.flats = nil + self.code = nil +end + +---Handler for provider request_symbols when outline is opened for the first time. +---@param response table? +---@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.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.bufnr, false, { + on_detach = function(_, _) + self:reset_state() + end, + }) + + self:setup_keymaps() + self:setup_buffer_autocmd() + self:setup_attached_buffer_autocmd() + + local items = parser.parse(response, self.code.buf) + self.items = items + + local current + self.flats, current = writer.make_outline(self.view.bufnr, items, self.code.win) + + self:update_cursor_pos(current) + + if not cfg.o.outline_window.focus_on_open or not opts.focus_outline then + vim.fn.win_gotoid(self.code.win) + end +end + +---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 table Passed to method +function Sidebar:nmap(cfg_name, method, args) + if type(method) == 'string' then + utils.nmap(self.view.bufnr, cfg.o.keymaps[cfg_name], function() + Sidebar[method](self, unpack(args)) + end) + else + utils.nmap(self.view.bufnr, cfg.o.keymaps[cfg_name], function() + method(unpack(args)) + end) + end +end + +function Sidebar:setup_keymaps() + for name, meth in pairs({ + -- stylua: ignore start + 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' } }, + hover_symbol = { require('outline.hover').show_hover, {} }, + toggle_preview = { require('outline.preview').toggle, {} }, + rename_symbol = { require('outline.rename').rename, {} }, + code_actions = { require('outline.code_action').show_code_actions, {} }, + show_help = { require('outline.docs').show_help, {} }, + close = { function() self.view:close() end, {} }, + 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', {} }, + -- stylua: ignore end + }) do + ---@diagnostic disable-next-line param-type-mismatch + self:nmap(name, meth[1], meth[2]) + end +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 = require('outline.preview').show, + }) + else + vim.api.nvim_create_autocmd('CursorMoved', { + buffer = 0, + callback = require('outline.preview').close, + }) + 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 self.autocmds[code_win] then + vim.api.nvim_del_autocmd(self.autocmds[code_win]) + self.autocmds[code_win] = nil + end + + if utils.str_or_nonempty_table(events.follow) then + self.autocmds[code_win] = vim.api.nvim_create_autocmd(events.follow, { + 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 + -- XXX: 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 + self.original_cursor = vim.o.guicursor + 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 + +---@param current outline.FlatSymbolNode? +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.winnr, { current.line_in_outline, col }) + end +end + +---Calls writer.make_outline and then calls M.update_cursor_pos if +-- update_cursor is not false +---@param update_cursor boolean? +---@param set_cursor_to_node outline.SymbolNode|outline.FlatSymbolNode? +function Sidebar:_update_lines(update_cursor, set_cursor_to_node) + local current + self.flats, current = writer.make_outline( + self.view.bufnr, + self.items, + self.code.win, + set_cursor_to_node + ) + if update_cursor ~= false then + self:update_cursor_pos(current) + end +end + +---Handler for provider request_symbols for refreshing outline +function Sidebar:refresh_handler(response) + if response == nil or type(response) ~= 'table' then + return + end + + local curwin = vim.api.nvim_get_current_win() + local curbuf = vim.api.nvim_get_current_buf() + local newbuf = curbuf ~= self.code.buf + + if self.code.win ~= curwin then + if self.autocmds[self.code.win] then + vim.api.nvim_del_autocmd(self.autocmds[self.code.win]) + self.autocmds[self.code.win] = nil + end + end + + self.code.win = curwin + self.code.buf = curbuf + + self:setup_attached_buffer_autocmd() + + 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.SymbolNode[] +function Sidebar:_merge_items(items) + utils.merge_items_rec({ children = items }, { children = self.items }) +end + +---Re-request symbols from provider +function Sidebar:__refresh() + local focused_outline = self.view.bufnr == vim.api.nvim_get_current_buf() + if self.view:is_open() and not focused_outline then + providers.request_symbols(function(res) self:refresh_handler(res) end) + end +end + +function Sidebar:_refresh() + (utils.debounce(function() self:__refresh() end, 100))() +end + +---@return outline.FlatSymbolNode +function Sidebar:_current_node() + local current_line = vim.api.nvim_win_get_cursor(self.view.winnr)[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 + +---@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.winnr, { 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.SymbolNode[] +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.SymbolNode[] +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 + +---@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 require('outline.preview').has_code_win(self.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 + local found = providers.request_symbols( + function(...) self:initial_handler(...) end, + opts + ) + if not found then + utils.echo('No providers found for current buffer') + end + end +end + +---@see outline.close_outline +function Sidebar:close() + self.view:close() +end + +---@see outline.focus_outline +---@return boolean is_open +function Sidebar:focus() + if self.view:is_open() then + vim.fn.win_gotoid(self.view.winnr) + return true + end + return false +end + +---@see outline.focus_code +---@return boolean ok +function Sidebar:focus_code() + if require('outline.preview').has_code_win(self.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 require('outline.preview').has_code_win(self.code.win) then + local winid = vim.fn.win_getid() + if winid == self.code.win then + vim.fn.win_gotoid(self.view.winnr) + 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.winnr +end + +---Whether there is currently an available provider. +---@return boolean has_provider +function Sidebar:has_provider() + if self:has_focus() then + return _G._outline_current_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.bufnr == 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 + +return Sidebar diff --git a/lua/outline/view.lua b/lua/outline/view.lua index febb57c..a728f9f 100644 --- a/lua/outline/view.lua +++ b/lua/outline/view.lua @@ -1,8 +1,9 @@ local cfg = require('outline.config') +---@class outline.View local View = {} ----@class View +---@class outline.View ---@field bufnr integer ---@field winnr integer @@ -46,7 +47,8 @@ function View:setup_view(split_command) -- mess with other theme/user settings. So just use empty spaces for now. vim.api.nvim_win_set_option(self.winnr, 'showbreak', ' ') -- only has effect when wrap=true. -- buffer stuff - vim.api.nvim_buf_set_name(self.bufnr, 'OUTLINE') + local tab = vim.api.nvim_get_current_tabpage() + vim.api.nvim_buf_set_name(self.bufnr, 'OUTLINE_'..tostring(tab)) vim.api.nvim_buf_set_option(self.bufnr, 'filetype', 'Outline') vim.api.nvim_buf_set_option(self.bufnr, 'modifiable', false)