diff --git a/autoload/SpaceVim/layers/core.vim b/autoload/SpaceVim/layers/core.vim index a1e7208c9..6310ae689 100644 --- a/autoload/SpaceVim/layers/core.vim +++ b/autoload/SpaceVim/layers/core.vim @@ -86,6 +86,9 @@ function! SpaceVim#layers#core#plugins() abort call add(plugins, [g:_spacevim_root_dir . 'bundle/defx-sftp',{'merged' : 0}]) elseif g:spacevim_filemanager ==# 'nvim-tree' call add(plugins, [g:_spacevim_root_dir . 'bundle/nvim-tree.lua',{'merged' : 0, 'loadconf' : 1}]) + elseif g:spacevim_filemanager ==# 'neo-tree' + call add(plugins, [g:_spacevim_root_dir . 'bundle/neo-tree.nvim',{'merged' : 0, 'loadconf' : 1}]) + call add(plugins, [g:_spacevim_root_dir . 'bundle/nui.nvim',{'merged' : 0}]) endif if !g:spacevim_vimcompatible diff --git a/autoload/SpaceVim/layers/core/statusline.vim b/autoload/SpaceVim/layers/core/statusline.vim index 2ae461c10..38402751b 100644 --- a/autoload/SpaceVim/layers/core/statusline.vim +++ b/autoload/SpaceVim/layers/core/statusline.vim @@ -452,6 +452,13 @@ function! SpaceVim#layers#core#statusline#get(...) abort \ . ' NvimTree ' \ . '%#SpaceVim_statusline_b_SpaceVim_statusline_c#' \ . s:lsep . ' ' + elseif &filetype ==# 'neo-tree' + return '%#SpaceVim_statusline_ia#' . s:winnr(1) + \ . '%#SpaceVim_statusline_ia_SpaceVim_statusline_b#' . s:lsep + \ . '%#SpaceVim_statusline_b#' + \ . ' NeoTree ' + \ . '%#SpaceVim_statusline_b_SpaceVim_statusline_c#' + \ . s:lsep . ' ' elseif &filetype ==# 'Fuzzy' return '%#SpaceVim_statusline_a_bold# Fuzzy %#SpaceVim_statusline_a_SpaceVim_statusline_b#' . s:lsep \ . '%#SpaceVim_statusline_b# %{fuzzy#statusline()} %#SpaceVim_statusline_b_SpaceVim_statusline_c#' . s:lsep diff --git a/bundle/neo-tree.nvim/.codecov.yml b/bundle/neo-tree.nvim/.codecov.yml new file mode 100644 index 000000000..7c4fa592a --- /dev/null +++ b/bundle/neo-tree.nvim/.codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + informational: true + only_pulls: true + patch: + default: + informational: true + only_pulls: true diff --git a/bundle/neo-tree.nvim/.github/workflows/ci.yml b/bundle/neo-tree.nvim/.github/workflows/ci.yml new file mode 100644 index 000000000..c67cb4d0c --- /dev/null +++ b/bundle/neo-tree.nvim/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI +on: + push: + branches: + - main + - v1.x + - v2.x + - v3.x + pull_request: + +jobs: + stylua-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + + - name: Check formatting + uses: JohnnyMorganz/stylua-action@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.16.0 + args: --color always --check -g '!**/defaults.lua' lua/ + + plenary-tests: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - run: date +%F > todays-date + - name: Restore cache for today's nightly. + uses: actions/cache@v2 + with: + path: build + key: ${{ runner.os }}-appimage-${{ hashFiles('todays-date') }} + + - name: Prepare + run: | + test -d build || { + mkdir -p build + wget https://github.com/neovim/neovim/releases/download/nightly/nvim.appimage + chmod +x nvim.appimage + mv nvim.appimage ./build/nvim + } + + # - name: Get Luver Cache Key + # id: luver-cache-key + # env: + # CI_RUNNER_OS: ${{ runner.os }} + # run: | + # echo "::set-output name=value::${CI_RUNNER_OS}-luver-v1-$(date -u +%Y-%m-%d)" + # shell: bash + # - name: Setup Luver Cache + # uses: actions/cache@v2 + # with: + # path: ~/.local/share/luver + # key: ${{ steps.luver-cache-key.outputs.value }} + + # - name: Setup Lua + # uses: MunifTanjim/luver-action@v1 + # with: + # default: 5.1.5 + # lua_versions: 5.1.5 + # luarocks_versions: 5.1.5:3.8.0 + # - name: Setup luacov + # run: | + # luarocks install luacov + + - name: Run tests + run: | + export PATH="${PWD}/build/:${PATH}" + ./scripts/test.sh + + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v2 diff --git a/bundle/neo-tree.nvim/.github/workflows/protect_release_branches.yml b/bundle/neo-tree.nvim/.github/workflows/protect_release_branches.yml new file mode 100644 index 000000000..8613a7149 --- /dev/null +++ b/bundle/neo-tree.nvim/.github/workflows/protect_release_branches.yml @@ -0,0 +1,29 @@ +# This is a basic workflow to help you get started with Actions + +name: No PRs to Release Branches + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the v1.x branch + pull_request: + types: [opened, edited, ready_for_review] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + check_target: + # The type of runner that the job will run on + runs-on: ubuntu-latest + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Runs a single command using the runners shell + - name: Fail when targeting v2 + run: | + target=${{ github.base_ref }} + echo "Target is: $target" + if [[ $target == "v2.x" ]]; then + echo "PRs must target main" + exit 1 + else + exit 0 + fi diff --git a/bundle/neo-tree.nvim/.gitignore b/bundle/neo-tree.nvim/.gitignore new file mode 100644 index 000000000..6bfe3810b --- /dev/null +++ b/bundle/neo-tree.nvim/.gitignore @@ -0,0 +1,47 @@ +# Compiled Lua sources +luac.out + +# luarocks build files +*.src.rock +*.zip +*.tar.gz + +# Object files +*.o +*.os +*.ko +*.obj +*.elf + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo +*.def +*.exp + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +# Vim tag files +tags + +# Others +.testcache +luacov.*.out diff --git a/bundle/neo-tree.nvim/.luacov b/bundle/neo-tree.nvim/.luacov new file mode 100644 index 000000000..400ec013b --- /dev/null +++ b/bundle/neo-tree.nvim/.luacov @@ -0,0 +1,3 @@ +include = { + "lua%/neo%-tree", +} diff --git a/bundle/neo-tree.nvim/.luarc.json b/bundle/neo-tree.nvim/.luarc.json new file mode 100644 index 000000000..c2d33c20a --- /dev/null +++ b/bundle/neo-tree.nvim/.luarc.json @@ -0,0 +1,3 @@ +{ + "diagnostics.globals": ["vim"] +} diff --git a/bundle/neo-tree.nvim/.stylua.toml b/bundle/neo-tree.nvim/.stylua.toml new file mode 100644 index 000000000..609d7739d --- /dev/null +++ b/bundle/neo-tree.nvim/.stylua.toml @@ -0,0 +1,5 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" diff --git a/bundle/neo-tree.nvim/.styluaignore b/bundle/neo-tree.nvim/.styluaignore new file mode 100644 index 000000000..42ee1635a --- /dev/null +++ b/bundle/neo-tree.nvim/.styluaignore @@ -0,0 +1 @@ +**/defaults.lua diff --git a/bundle/neo-tree.nvim/CONTRIBUTING.md b/bundle/neo-tree.nvim/CONTRIBUTING.md new file mode 100644 index 000000000..fb3400f26 --- /dev/null +++ b/bundle/neo-tree.nvim/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing to Neo-tree + +Contributions are welcome! To keep everything clean and tidy, please follow the +guidelines below. + +## Code Style + +This is open for debate, but here is the current style choices being observed: + +- snake_case for all variables and functions +- unless it is a class, then use PascalCase +- other OOP things, like method names should use camelCase +- BUT we don't currently have any OOP parts and I don't think we want any + +I prefer `local name = function()` over `local function name()`, just to be +consistent with the `M.name = function()` exports. + +### StyLua + +We use (StyLua)[https://github.com/JohnnyMorganz/StyLua] to enforce consistency +in code. You should install it on your local machine. PRs will be checked with +this tool. + +## Commit Messages + +We use **semantic**, aka **conventional** commit messages. The official guide +can be found here: https://www.conventionalcommits.org/en/v1.0.0/ + +You can also just take a look at the commit history to get the idea. The +optional scope for this project would usually be the source, i.e. +`feat(filesystem): add awesome feature that does xyz`. + +## Branching + +The default branch is set to the current major version to make it simple for end +users visiting the repo. Pull Requests, however, should go to the `main` +branch. After a short testing period, it will be merged to the current release +branch. + +This project requires a **linear history**. I don't trust merge commits. +This means you will have to rebase your branch on main before the pull request +can be merged. This can get a bit annoying in a busy repository, but I think it +is worth the effort. + +## Documentation + +All new features should be documented in the commit they were added in. The +current strategy is to maintain: + +- Config Options: added to [defaults](lua/neo-tree/defaults.lua) and described + in comments +- The README contains "back of the box" high level overview of features. It is + meant for people trying to decide if they want to install this plugin or not. + It should include references to the help file for more information: + `:h neo-tree-setup` +- The vim help file [doc/neo-tree.txt](doc/neo-tree.txt) is the definitive + reference and should contain all information needed to configure and use the + plugin. diff --git a/bundle/neo-tree.nvim/Dockerfile b/bundle/neo-tree.nvim/Dockerfile new file mode 100644 index 000000000..734c2524f --- /dev/null +++ b/bundle/neo-tree.nvim/Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu:22.04 + +RUN apt update +# install neovim dependencies +RUN apt install -y git ninja-build gettext libtool libtool-bin autoconf \ + automake cmake g++ pkg-config unzip curl doxygen + +# install neovim +RUN git clone https://github.com/neovim/neovim +RUN cd neovim && make CMAKE_BUILD_TYPE=RelWithDebInfo && make install + +# install required plugins +ARG PLUG_DIR="root/.local/share/nvim/site/pack/packer/start" +RUN git clone https://github.com/nvim-lua/plenary.nvim $PLUG_DIR/plenary.nvim +RUN git clone https://github.com/MunifTanjim/nui.nvim $PLUG_DIR/nui.nvim +COPY . $PLUG_DIR/neo-tree.nvim + +WORKDIR $PLUG_DIR/neo-tree.nvim diff --git a/bundle/neo-tree.nvim/LICENSE b/bundle/neo-tree.nvim/LICENSE new file mode 100644 index 000000000..74eaaf885 --- /dev/null +++ b/bundle/neo-tree.nvim/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2021 cseickel (https://github.com/cseickel) and nvim-neo-tree +maintainers. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bundle/neo-tree.nvim/Makefile b/bundle/neo-tree.nvim/Makefile new file mode 100644 index 000000000..58d929166 --- /dev/null +++ b/bundle/neo-tree.nvim/Makefile @@ -0,0 +1,12 @@ +.PHONY: test +test: + nvim --headless --noplugin -u tests/mininit.lua -c "lua require('plenary.test_harness').test_directory('tests/neo-tree/', {minimal_init='tests/mininit.lua',sequential=true})" + +.PHONY: test-docker +test-docker: + docker build -t neo-tree . + docker run --rm neo-tree make test + +.PHONY: format +format: + stylua --glob '*.lua' --glob '!defaults.lua' . diff --git a/bundle/neo-tree.nvim/README.md b/bundle/neo-tree.nvim/README.md new file mode 100644 index 000000000..1d631e2cd --- /dev/null +++ b/bundle/neo-tree.nvim/README.md @@ -0,0 +1,770 @@ +# Neo-tree.nvim + +Neo-tree is a Neovim plugin to browse the file system and other tree like +structures in whatever style suits you, including sidebars, floating windows, +netrw split style, or all of them at once! + +![Neo-tree file system](https://github.com/nvim-neo-tree/resources/blob/main/images/Neo-tree-with-right-aligned-symbols.png) + +### Breaking Changes BAD :bomb: :imp: + +The biggest and most important feature of Neo-tree is that we will never +knowingly push a breaking change and interrupt your day. Bugs happen, but +breaking changes can always be avoided. When breaking changes are needed, there +will be a new branch that you can opt into, when it is a good time for you. + +See [What is a Breaking Change?](#what-is-a-breaking-change) for details. + +See [Changelog 2.0](https://github.com/nvim-neo-tree/neo-tree.nvim/wiki/Changelog#20) +for breaking changes and deprecations in 2.0. + + +### User Experience GOOD :slightly_smiling_face: :thumbsup: + +Aside from being polite about breaking changes, Neo-tree is also focused on the +little details of user experience. Everything should work exactly as you would +expect a sidebar to work without all of the glitchy behavior that is normally +accepted in (neo)vim sidebars. I can't stand glitchy behavior, and neither +should you! + +- Neo-tree won't let other buffers take over its window. +- Neo-tree won't leave its window scrolled to the last line when there is + plenty of room to display the whole tree. +- Neo-tree does not need to be manually refreshed (set `use_libuv_file_watcher=true`) +- Neo-tree can intelligently follow the current file (set `follow_current_file=true`) +- Neo-tree is thoughtful about maintaining or setting focus on the right node +- Neo-tree windows in different tabs are completely separate +- `respect_gitignore` actually works! + +Neo-tree is smooth, efficient, stable, and pays attention to the little details. +If you find anything janky, wanky, broken, or unintuitive, please open an issue +so we can fix it. + + +## Minimal Quickstart + +#### Minimal Example for Packer: +```lua +-- Unless you are still migrating, remove the deprecated commands from v1.x +vim.cmd([[ let g:neo_tree_remove_legacy_commands = 1 ]]) + +use { + "nvim-neo-tree/neo-tree.nvim", + branch = "v2.x", + requires = { + "nvim-lua/plenary.nvim", + "nvim-tree/nvim-web-devicons", -- not strictly required, but recommended + "MunifTanjim/nui.nvim", + } + } +``` + +After installing, run: +``` +:Neotree +``` + +Press `?` in the Neo-tree window to view the list of mappings. + + +## Quickstart + +#### Longer Example for Packer: + +```lua +use { + "nvim-neo-tree/neo-tree.nvim", + branch = "v2.x", + requires = { + "nvim-lua/plenary.nvim", + "nvim-tree/nvim-web-devicons", -- not strictly required, but recommended + "MunifTanjim/nui.nvim", + { + -- only needed if you want to use the commands with "_with_window_picker" suffix + 's1n7ax/nvim-window-picker', + tag = "v1.*", + config = function() + require'window-picker'.setup({ + autoselect_one = true, + include_current = false, + filter_rules = { + -- filter using buffer options + bo = { + -- if the file type is one of following, the window will be ignored + filetype = { 'neo-tree', "neo-tree-popup", "notify" }, + + -- if the buffer type is one of following, the window will be ignored + buftype = { 'terminal', "quickfix" }, + }, + }, + other_win_hl_color = '#e35e4f', + }) + end, + } + }, + config = function () + -- Unless you are still migrating, remove the deprecated commands from v1.x + vim.cmd([[ let g:neo_tree_remove_legacy_commands = 1 ]]) + + -- If you want icons for diagnostic errors, you'll need to define them somewhere: + vim.fn.sign_define("DiagnosticSignError", + {text = " ", texthl = "DiagnosticSignError"}) + vim.fn.sign_define("DiagnosticSignWarn", + {text = " ", texthl = "DiagnosticSignWarn"}) + vim.fn.sign_define("DiagnosticSignInfo", + {text = " ", texthl = "DiagnosticSignInfo"}) + vim.fn.sign_define("DiagnosticSignHint", + {text = "", texthl = "DiagnosticSignHint"}) + -- NOTE: this is changed from v1.x, which used the old style of highlight groups + -- in the form "LspDiagnosticsSignWarning" + + require("neo-tree").setup({ + close_if_last_window = false, -- Close Neo-tree if it is the last window left in the tab + popup_border_style = "rounded", + enable_git_status = true, + enable_diagnostics = true, + open_files_do_not_replace_types = { "terminal", "trouble", "qf" }, -- when opening files, do not use windows containing these filetypes or buftypes + sort_case_insensitive = false, -- used when sorting files and directories in the tree + sort_function = nil , -- use a custom function for sorting files and directories in the tree + -- sort_function = function (a,b) + -- if a.type == b.type then + -- return a.path > b.path + -- else + -- return a.type > b.type + -- end + -- end , -- this sorts files and directories descendantly + default_component_configs = { + container = { + enable_character_fade = true + }, + indent = { + indent_size = 2, + padding = 1, -- extra padding on left hand side + -- indent guides + with_markers = true, + indent_marker = "│", + last_indent_marker = "└", + highlight = "NeoTreeIndentMarker", + -- expander config, needed for nesting files + with_expanders = nil, -- if nil and file nesting is enabled, will enable expanders + expander_collapsed = "", + expander_expanded = "", + expander_highlight = "NeoTreeExpander", + }, + icon = { + folder_closed = "", + folder_open = "", + folder_empty = "ﰊ", + -- The next two settings are only a fallback, if you use nvim-web-devicons and configure default icons there + -- then these will never be used. + default = "*", + highlight = "NeoTreeFileIcon" + }, + modified = { + symbol = "[+]", + highlight = "NeoTreeModified", + }, + name = { + trailing_slash = false, + use_git_status_colors = true, + highlight = "NeoTreeFileName", + }, + git_status = { + symbols = { + -- Change type + added = "", -- or "✚", but this is redundant info if you use git_status_colors on the name + modified = "", -- or "", but this is redundant info if you use git_status_colors on the name + deleted = "✖",-- this can only be used in the git_status source + renamed = "",-- this can only be used in the git_status source + -- Status type + untracked = "", + ignored = "", + unstaged = "", + staged = "", + conflict = "", + } + }, + }, + -- A list of functions, each representing a global custom command + -- that will be available in all sources (if not overridden in `opts[source_name].commands`) + -- see `:h neo-tree-global-custom-commands` + commands = {}, + window = { + position = "left", + width = 40, + mapping_options = { + noremap = true, + nowait = true, + }, + mappings = { + [""] = { + "toggle_node", + nowait = false, -- disable `nowait` if you have existing combos starting with this char that you want to use + }, + ["<2-LeftMouse>"] = "open", + [""] = "open", + [""] = "revert_preview", + ["P"] = { "toggle_preview", config = { use_float = true } }, + ["l"] = "focus_preview", + ["S"] = "open_split", + ["s"] = "open_vsplit", + -- ["S"] = "split_with_window_picker", + -- ["s"] = "vsplit_with_window_picker", + ["t"] = "open_tabnew", + -- [""] = "open_drop", + -- ["t"] = "open_tab_drop", + ["w"] = "open_with_window_picker", + --["P"] = "toggle_preview", -- enter preview mode, which shows the current node without focusing + ["C"] = "close_node", + -- ['C'] = 'close_all_subnodes', + ["z"] = "close_all_nodes", + --["Z"] = "expand_all_nodes", + ["a"] = { + "add", + -- this command supports BASH style brace expansion ("x{a,b,c}" -> xa,xb,xc). see `:h neo-tree-file-actions` for details + -- some commands may take optional config options, see `:h neo-tree-mappings` for details + config = { + show_path = "none" -- "none", "relative", "absolute" + } + }, + ["A"] = "add_directory", -- also accepts the optional config.show_path option like "add". this also supports BASH style brace expansion. + ["d"] = "delete", + ["r"] = "rename", + ["y"] = "copy_to_clipboard", + ["x"] = "cut_to_clipboard", + ["p"] = "paste_from_clipboard", + ["c"] = "copy", -- takes text input for destination, also accepts the optional config.show_path option like "add": + -- ["c"] = { + -- "copy", + -- config = { + -- show_path = "none" -- "none", "relative", "absolute" + -- } + --} + ["m"] = "move", -- takes text input for destination, also accepts the optional config.show_path option like "add". + ["q"] = "close_window", + ["R"] = "refresh", + ["?"] = "show_help", + ["<"] = "prev_source", + [">"] = "next_source", + } + }, + nesting_rules = {}, + filesystem = { + filtered_items = { + visible = false, -- when true, they will just be displayed differently than normal items + hide_dotfiles = true, + hide_gitignored = true, + hide_hidden = true, -- only works on Windows for hidden files/directories + hide_by_name = { + --"node_modules" + }, + hide_by_pattern = { -- uses glob style patterns + --"*.meta", + --"*/src/*/tsconfig.json", + }, + always_show = { -- remains visible even if other settings would normally hide it + --".gitignored", + }, + never_show = { -- remains hidden even if visible is toggled to true, this overrides always_show + --".DS_Store", + --"thumbs.db" + }, + never_show_by_pattern = { -- uses glob style patterns + --".null-ls_*", + }, + }, + follow_current_file = false, -- This will find and focus the file in the active buffer every + -- time the current file is changed while the tree is open. + group_empty_dirs = false, -- when true, empty folders will be grouped together + hijack_netrw_behavior = "open_default", -- netrw disabled, opening a directory opens neo-tree + -- in whatever position is specified in window.position + -- "open_current", -- netrw disabled, opening a directory opens within the + -- window like netrw would, regardless of window.position + -- "disabled", -- netrw left alone, neo-tree does not handle opening dirs + use_libuv_file_watcher = false, -- This will use the OS level file watchers to detect changes + -- instead of relying on nvim autocmd events. + window = { + mappings = { + [""] = "navigate_up", + ["."] = "set_root", + ["H"] = "toggle_hidden", + ["/"] = "fuzzy_finder", + ["D"] = "fuzzy_finder_directory", + ["#"] = "fuzzy_sorter", -- fuzzy sorting using the fzy algorithm + -- ["D"] = "fuzzy_sorter_directory", + ["f"] = "filter_on_submit", + [""] = "clear_filter", + ["[g"] = "prev_git_modified", + ["]g"] = "next_git_modified", + }, + fuzzy_finder_mappings = { -- define keymaps for filter popup window in fuzzy_finder_mode + [""] = "move_cursor_down", + [""] = "move_cursor_down", + [""] = "move_cursor_up", + [""] = "move_cursor_up", + }, + }, + + commands = {} -- Add a custom command or override a global one using the same function name + }, + buffers = { + follow_current_file = true, -- This will find and focus the file in the active buffer every + -- time the current file is changed while the tree is open. + group_empty_dirs = true, -- when true, empty folders will be grouped together + show_unloaded = true, + window = { + mappings = { + ["bd"] = "buffer_delete", + [""] = "navigate_up", + ["."] = "set_root", + } + }, + }, + git_status = { + window = { + position = "float", + mappings = { + ["A"] = "git_add_all", + ["gu"] = "git_unstage_file", + ["ga"] = "git_add_file", + ["gr"] = "git_revert_file", + ["gc"] = "git_commit", + ["gp"] = "git_push", + ["gg"] = "git_commit_and_push", + } + } + } + }) + + vim.cmd([[nnoremap \ :Neotree reveal]]) + end +} +``` + +_The above configuration is not everything that can be changed, it's just the +parts you might want to change first._ + + +See `:h neo-tree` for full documentation. You can also preview that online at +[doc/neo-tree.txt](doc/neo-tree.txt), although it's best viewed within vim. + + +To see all of the default config options with commentary, you can view it online +at [lua/neo-tree/defaults.lua](lua/neo-tree/defaults.lua). You can also paste it +into a buffer after installing Neo-tree by running: + +``` +:lua require("neo-tree").paste_default_config() +``` + +#### Configuration for Nerd Fonts v3 Users + +The following configuration should fix broken icons if you are using Nerd Fonts v3: + +```lua +require("neo-tree").setup({ + default_component_configs = { + icon = { + folder_empty = "󰜌", + folder_empty_open = "󰜌", + }, + git_status = { + symbols = { + renamed = "󰁕", + unstaged = "󰄱", + }, + }, + }, + document_symbols = { + kinds = { + File = { icon = "󰈙", hl = "Tag" }, + Namespace = { icon = "󰌗", hl = "Include" }, + Package = { icon = "󰏖", hl = "Label" }, + Class = { icon = "󰌗", hl = "Include" }, + Property = { icon = "󰆧", hl = "@property" }, + Enum = { icon = "󰒻", hl = "@number" }, + Function = { icon = "󰊕", hl = "Function" }, + String = { icon = "󰀬", hl = "String" }, + Number = { icon = "󰎠", hl = "Number" }, + Array = { icon = "󰅪", hl = "Type" }, + Object = { icon = "󰅩", hl = "Type" }, + Key = { icon = "󰌋", hl = "" }, + Struct = { icon = "󰌗", hl = "Type" }, + Operator = { icon = "󰆕", hl = "Operator" }, + TypeParameter = { icon = "󰊄", hl = "Type" }, + StaticMethod = { icon = '󰠄 ', hl = 'Function' }, + } + }, + -- Add this section only if you've configured source selector. + source_selector = { + sources = { + { source = "filesystem", display_name = " 󰉓 Files " }, + { source = "git_status", display_name = " 󰊢 Git " }, + }, + }, + -- Other options ... +}) +``` + +## The `:Neotree` Command + +The single `:Neotree` command accepts a range of arguments that give you full +control over the details of what and where it will show. For example, the following +command will open a file browser on the right hand side, "revealing" the currently +active file: + +``` +:Neotree filesystem reveal right +``` + +Arguments can be specified as either a key=value pair or just as the value. The +key=value form is more verbose but may help with clarity. For example, the command +above can also be specified as: + +``` +:Neotree source=filesystem reveal=true position=right +``` + +All arguments are optional and can be specified in any order. If you issue the command +without any arguments, it will use default values for everything. For example: + +``` +:Neotree +``` + +will open the filesystem source on the left hand side and focus it, if you are using +the default config. + +### Tab Completion + +Neotree supports tab completion for all arguments. Once a given argument has a value, +it will stop suggesting those completions. It will also offer completions for paths. +The simplest way to disambiguate a path from another type of argument is to start +them with `/` or `./`. + +### Arguments + +Here is the full list of arguments you can use: + +#### `action` +What to do. Can be one of: + +| Option | Description | +|--------|-------------| +| focus | Show and/or switch focus to the specified Neotree window. DEFAULT | +| show | Show the window, but keep focus on your current window. | +| close | Close the window(s) specified. Can be combined with "position" and/or "source" to specify which window(s) to close. | + +#### `source` +What to show. Can be one of: + +| Option | Description | +|--------|-------------| +| filesystem | Show a file browser. DEFAULT | +| buffers | Show a list of currently open buffers. | +| git_status | Show the output of `git status` in a tree layout. | + +#### `position` +Where to show it, can be one of: + +| Option | Description | +|---------|-------------| +| left | Open as left hand sidebar. DEFAULT | +| right | Open as right hand sidebar. | +| top | Open as top window. | +| bottom | Open as bottom window. | +| float | Open as floating window. | +| current | Open within the current window, like netrw or vinegar would. | + +#### `toggle` +This is a boolean flag. Adding this means that the window will be closed if it +is already open. + +#### `dir` +The directory to set as the root/cwd of the specified window. If you include a +directory as one of the arguments, it will be assumed to be this option, you +don't need the full dir=/path. You may use any value that can be passed to the +'expand' function, such as `%:p:h:h` to specify two directories up from the +current file. For example: + +``` +:Neotree ./relative/path +:Neotree /home/user/relative/path +:Neotree dir=/home/user/relative/path +:Neotree position=current dir=relative/path +``` + +#### `git_base` +The base that is used to calculate the git status for each dir/file. +By default it uses `HEAD`, so it shows all changes that are not yet committed. +You can for example work on a feature branch, and set it to `main`. It will +show all changes that happened on the feature branch and main since you +branched off. + +Any git ref, commit, tag, or sha will work. + +``` +:Neotree main +:Neotree v1.0 +:Neotree git_base=8fe34be +:Neotree git_base=HEAD +``` + +#### `reveal` +This is a boolean flag. Adding this will make Neotree automatically find and +focus the current file when it opens. + +#### `reveal_file` +A path to a file to reveal. This supersedes the "reveal" flag so there is no +need to specify both. Use this if you want to reveal something other than the +current file. If you include a path to a file as one of the arguments, it will +be assumed to be this option. Like "dir", you can pass any value that can be +passed to the 'expand' function. For example: + +``` +:Neotree reveal_file=/home/user/my/file.text +:Neotree position=current dir=%:p:h:h reveal_file=%:p +:Neotree current %:p:h:h %:p +``` + +One neat trick you can do with this is to open a Neotree window which is +focused on the file under the cursor using the `` keyword: + +``` +nnoremap gd :Neotree float reveal_file= reveal_force_cwd +``` + +#### `reveal_force_cwd` +This is a boolean flag. Normally, if you use one of the reveal options and the +given file is not within the current working directory, you will be asked if you +want to change the current working directory. If you include this flag, it will +automatically change the directory without prompting. This option implies +"reveal", so you do not need to specify both. + +See `:h neo-tree-commands` for details and a full listing of available arguments. + +### File Nesting + +See `:h neo-tree-file-nesting` for more details about file nesting. + + +### Netrw Hijack + +``` +:edit . +:[v]split . +``` + +If `"filesystem.window.position"` is set to `"current"`, or if you have specified +`filesystem.hijack_netrw_behavior = "open_current"`, then any command +that would open a directory will open neo-tree in the specified window. + + +## Sources + +Neo-tree is built on the idea of supporting various sources. Sources are +basically interface implementations whose job it is to provide a list of +hierarchical items to be rendered, along with commands that are appropriate to +those items. + +### filesystem +The default source is `filesystem`, which displays your files and folders. This +is the default source in commands when none is specified. + +This source can be used to: +- Browse the filesystem +- Control the current working directory of nvim +- Add/Copy/Delete/Move/Rename files and directories +- Search the filesystem +- Monitor git status and lsp diagnostics for the current working directory + +### buffers +![Neo-tree buffers](https://github.com/nvim-neo-tree/resources/raw/main/images/Neo-tree-buffers.png) + +Another available source is `buffers`, which displays your open buffers. This is +the same list you would see from `:ls`. To show with the `buffers` list, use: + +``` +:Neotree buffers +``` + +### git_status +This view take the results of the `git status` command and display them in a +tree. It includes commands for adding, unstaging, reverting, and committing. + +The screenshot below shows the result of `:Neotree float git_status` while the +filesystem is open in a sidebar: + +![Neo-tree git_status](https://github.com/nvim-neo-tree/resources/raw/main/images/Neo-tree-git_status.png) + +You can specify a different git base here as well. But be aware that it is not +possible to unstage / revert a file that is already committed. + +``` +:Neotree float git_status git_base=main +``` + +### document_symbols + +![Neo-tree document_symbols](https://github.com/nvim-neo-tree/resources/raw/main/images/neo-tree-document-symbols.png) +The document_symbols source lists the symbols in the current document obtained +by the LSP request "textDocument/documentSymbols". It currently supports the +following features: +- [x] UI: + - [x] Display all symbols in the current file with symbol kinds + - [x] Symbols nesting + - [x] Configurable kinds' name and icon + - [x] Auto-refresh symbol list + - [x] Follow cursor +- [ ] Commands + - [x] Jump to symbols, open symbol in split,... (`open_split` and friends) + - [x] Rename symbols (`rename`) + - [x] Preview symbol (`preview` and friends) + - [ ] Hover docs + - [ ] Call hierarchy +- [x] LSP + - [x] LSP Support + - [x] LSP server selection (ignore, allow_only, use first, use all, etc.) +- [ ] CoC Support + +See #879 for the tracking issue of these features. + +This source is currently experimental, so in order to use it, you need to first +add `"document_symbols"` to `config.sources` and open it with the command +``` +:Neotree document_symbols +``` + + + +### Source Selector +![Neo-tree source selector](https://github.com/nvim-neo-tree/resources/raw/main/images/Neo-tree-source-selector.png) + +You can enable a clickable source selector in either the winbar (requires neovim 0.8+) or the statusline. +To do so, set one of these options to `true`: + +```lua + require("neo-tree").setup({ + source_selector = { + winbar = false, + statusline = false + } + }) +``` + +There are many configuration options to change the style of these tabs. +See [lua/neo-tree/defaults.lua](lua/neo-tree/defaults.lua) for details. + + +## Configuration and Customization + +This is designed to be flexible. The way that is achieved is by making +everything a function, or a string that identifies a built-in function. All of the +built-in functions can be replaced with your own implementation, or you can +add new ones. + +Each node in the tree is created from the renderer specified for the given node +type, and each renderer is a list of component configs to be rendered in order. +Each component is a function, either built-in or specified in your config. Those +functions simply return the text and highlight group for the component. + +Additionally, there is an events system that you can hook into. If you want to +show some new data point related to your files, gather it in the +`before_render` event, create a component to display it, and reference that +component in the renderer for the `file` and/or `directory` type. + +Details on how to configure everything is in the help file at `:h +neo-tree-configuration` or online at +[neo-tree.txt](https://github.com/nvim-neo-tree/neo-tree.nvim/blob/main/doc/neo-tree.txt) + +Recipes for customizations can be found on the [wiki](https://github.com/nvim-neo-tree/neo-tree.nvim/wiki/Recipes). Recipes include +things like adding a component to show the +[Harpoon](https://github.com/ThePrimeagen/harpoon) index for files, or +responding to the `"file_opened"` event to auto clear the search when you open a +file. + + +## Why? + +There are many tree plugins for (neo)vim, so why make another one? Well, I +wanted something that was: + +1. Easy to maintain and enhance. +2. Stable. +3. Easy to customize. + +### Easy to maintain and enhance + +This plugin is designed to grow and be flexible. This is accomplished by making +the code as decoupled and functional as possible. Hopefully new contributors +will find it easy to work with. + +One big difference between this plugin and the ones that came before it, which +is also what finally pushed me over the edge into making a new plugin, is that +we now have libraries to build upon that did not exist when other tree plugins +were created. Most notably, [nui.nvim](https://github.com/MunifTanjim/nui.nvim) +and [plenary.nvm](https://github.com/nvim-lua/plenary.nvim). Building upon +shared libraries will go a long way in making neo-tree easy to maintain. + +### Stable + +This project will have releases and release tags that follow a simplified +Semantic Versioning scheme. The quickstart instructions will always refer to +the latest stable major version. Following the **main** branch is for +contributors and those that always want bleeding edge. There will be branches +for **v1.x**, **v2.x**, etc which will receive updates after a short testing +period in **main**. You should be safe to follow those branches and be sure +your tree won't break in an update. There will also be tags for each release +pushed to those branches named **v1.1**, **v1.2**, etc. If stability is +critical to you, or a bug accidentally make it into **v1.x**, you can use those +tags instead. It's possible we may backport bug fixes to those tags, but no +garauntees on that. + +There will never be a breaking change within a major version (1.x, 2.x, etc.) If +a breaking change is needed, there will be depracation warnings in the prior +major version, and the breaking change will happen in the next major version. + +### Easy to Customize + +Neo-tree follows in the spirit of plugins like +[lualine.nvim](https://github.com/nvim-lualine/lualine.nvim) and +[nvim-cokeline](https://github.com/noib3/nvim-cokeline). Everything will be +configurable and take either strings, tables, or functions. You can take sane +defaults or build your tree items from scratch. There should be the ability to +add any features you can think of through existing hooks in the setup function. + +## What is a Breaking Change? + +As of v1.30, a breaking change is defined as anything that _changes_ existing: + +- vim commands (`:NeoTreeShow`, `:NeoTreeReveal`, etc) +- configuration options that are passed into the `setup()` function +- `NeoTree*` highlight groups +- lua functions exported in the following modules that are not prefixed with `_`: + * `neo-tree` + * `neo-tree.events` + * `neo-tree.sources.manager` + * `neo-tree.sources.*` (init.lua files) + * `neo-tree.sources.*.commands` + * `neo-tree.ui.renderer` + * `neo-tree.utils` + +If there are other functions you would like to use that are not yet considered +part of the public API, please open an issue so we can discuss it. + +## Contributions + +Contributions are encouraged. Please see [CONTRIBUTING](CONTRIBUTING.md) for more details. + +## Acknowledgements + +This project relies upon these two excellent libraries: +- [nui.nvim](https://github.com/MunifTanjim/nui.nvim) for all UI components, including the tree! +- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) for backend utilities, such as scanning the filesystem. + +The design is heavily inspired by these excellent plugins: +- [lualine.nvim](https://github.com/nvim-lualine/lualine.nvim) +- [nvim-cokeline](https://github.com/noib3/nvim-cokeline) + +Everything I know about writing a tree control in lua, I learned from: +- [nvim-tree.lua](https://github.com/nvim-tree/nvim-tree.lua) diff --git a/bundle/neo-tree.nvim/doc/neo-tree.txt b/bundle/neo-tree.nvim/doc/neo-tree.txt new file mode 100644 index 000000000..b3e0cf8df --- /dev/null +++ b/bundle/neo-tree.nvim/doc/neo-tree.txt @@ -0,0 +1,1747 @@ +*neo-tree.txt* Plugin to browse the file system and other tree like structures + +CONTENTS *neo-tree* +Introduction ................ |neo-tree-introduction| +Commands .................... |neo-tree-commands| +Mappings .................... |neo-tree-mappings| + Help ...................... |neo-tree-help| + Navigation ................ |neo-tree-navigation| + View Changes .............. |neo-tree-view-changes| + File Actions .............. |neo-tree-file-actions| + Filter .................... |neo-tree-filter| + Global custom commands .... |neo-tree-custom-commands-global| +Configuration ............... |neo-tree-configuration| + Setup ..................... |neo-tree-setup| + Source Selector ........... |neo-tree-source-selector| + Filtered Items ............ |neo-tree-filtered-items| + Preview Mode .............. |neo-tree-preview-mode| + Hijack Netrw Behavior ..... |neo-tree-netrw-hijack| + Component Configs ......... |neo-tree-component-configs| + Git Status ................ |neo-tree-git-status| + Diagnostics ............... |neo-tree-diagnostics| + Indent markers ............ |neo-tree-indent-markers| + Expanders ................. |neo-tree-expanders| + File nesting .............. |neo-tree-file-nesting| + Highlights ................ |neo-tree-highlights| + Events .................... |neo-tree-events| + Components and Renderers .. |neo-tree-renderers| + Buffer Variables .......... |neo-tree-buffer-variables| + Popups .................... |neo-tree-popups| +Other Sources ............... |neo-tree-sources| + Buffers ................... |neo-tree-buffers| + Git Status ................ |neo-tree-git-status-source| + Document Symbols .......... |neo-tree-document-symbols| + + +INTRODUCTION *neo-tree-introduction* + +Neo-tree is a plugin for nvim that can display tree structures in a sidebar, +floating window, or in a split. The current version includes a filesystem +browser, a buffer list, and a git status view. + +If you want to use this in splits like |netrw| instead of as a sidebar or +floating window, use the *Split commands, such as |NeoTreeRevealInSplit|. You +may use both styles at the same time if you want. + + +COMMANDS *:Neotree* *neo-tree-commands* + +Neo-tree does not define any default keybindings for nvim. The suggested +keybindings are: + + nnoremap / :Neotree toggle current reveal_force_cwd + nnoremap | :Neotree reveal + nnoremap gd :Neotree float reveal_file= reveal_force_cwd + nnoremap b :Neotree toggle show buffers right + nnoremap s :Neotree float git_status + +The single |:Neotree| command accepts a range of arguments that give you full +control over the details of what and where it will show. Arguments can be +specified as either a key=value pair or just as the value. The key=value form +is more verbose but may help with clarity. For example, the "buffers" command +above can also be specified as: +>vim + :Neotree action=show source=buffers position=right toggle=true +< +These arguments can be specified in any order. Here is the full list of +arguments you can use: + +action~ +What to do. Can be one of: + + focus : Show and/or switch focus to the specified Neotree window. DEFAULT + show : Show the window, but keep focus on your current window. + close : Close the window(s) specified. Can be combined with "position" + and/or "source" to specify which window(s) to close. + +source~ +What to show. Can be one of: + + filesystem : Show a file browser. DEFAULT + buffers : Show a list of currently open buffers. + git_status : Show the output of `git status` in a tree layout. + +position~ +Where to show it, can be one of: + + left : Open as left hand sidebar. DEFAULT + right : Open as right hand sidebar. + float : Open as floating window. + current : Open within the current window, like netrw or vinegar would. + +toggle~ +This is a boolean flag. Adding this means that the window will be closed if it +is already open. + +dir~ +The directory to set as the root/cwd of the specified window. If you include a +directory as one of the arguments, it will be assumed to be this option, you +don't need the full dir=/path. You may use any value that can be passed to the +'expand' function, such as `%:p:h:h` to specify two directories up from the +current file. + +git_base~ +The base that is used to calculate the git status for each dir/file. +By default it uses `HEAD`, so it shows all changes that are not yet committed. +You can for example work on a feature branch, and set it to `main`. It will +show all changes that happened on the feature branch and main since you +branched off. + +Any git ref, commit, tag, or sha will work. + +reveal~ +This is a boolean flag. Adding this will make Neotree automatically find and +focus the current file when it opens. + +reveal_path~ +A path to a file to reveal. This supersedes the "reveal" flag so there is no +need to specify both. Use this if you want to reveal something other than the +current file. If you include a path to a file as one of the arguments, it will +be assumed to be this option. Like "dir", you can pass any value that can be +passed to the 'expand' function. + +reveal_force_cwd~ +This is a boolean flag. Normally, if you use one of the reveal options and the +given file is not within the current working directory, you will be asked if you +want to change the current working directory. If you include this flag, it will +automatically change the directory without prompting. This option implies +"reveal", so you do not need to specify both. + +DEPRECATED_COMMANDS + +The following commands from Neotree v1.x will remain until v3.0 for backwards +compatibility. You should not use them if you are a new user, and existing users +should change their mapping to the new |:Neotree| command specified above. + + :NeoTreeFocus -> :Neotree + :NeoTreeShow -> :Neotree show + :NeoTreeShowInSplit -> :Neotree show current + :NeoTreeReveal -> :Neotree reveal + :NeoTreeRevealInSplit -> :Neotree reveal current + :NeoTreeFloat -> :Neotree float + :NeoTreeClose -> :Neotree close + :NeoTreePasteConfig -> :lua require("neo-tree").paste_default_config() + +Once you have migrated your mappings, it is recommended that you remove the old +commands by adding this option before the Neotree config is run: +>vim + let g:neo_tree_remove_legacy_commands = 1 +< + +=============================================================================== +MAPPINGS ~ +=============================================================================== + *neo-tree-mappings* + +HELP *neo-tree-help* + +? = show_help: Shows a popup window with all of the mappings for the current + Neotree window. Pressing one of those keys will close the help + screen and execute the chosen command in the original Neotree + window. NOTE that selecting a line in the help window and + pressing enter will not execute that command, it will just + execute whatever the enter key is mapped to. + + +NAVIGATION *neo-tree-navigation* + +Within the neo-tree window, for the filesystem source, the following mappings +are defined by default. All built-in commands are listed here but some are not +mapped by default. See |neo-tree-custom-commands| for details on how to use them +in a custom mapping. + +Note: The "selected" item is the line the cursor is currently on. + +< = prev_source: Switches to the previous source. + +> = next_source: Switches to the next source. + + = navigate_up: Moves the root directory up one level. + +. = set_root: Changes the root directory to the currently + selected folder. + + = toggle_node Expand or collapse a node with children, which + may be a directory or a nested file. + +<2-LeftMouse> = open: Expand or collapse a folder. If a file is selected, + open it in the window closest to the tree. + + = open: Same as above. + +C = close_node: Close node if it is open, else close it's parent. + +z = close_all_nodes: Close all nodes in the tree. + + close_all_subnodes: Same as "close_node", but also recursively collapse + all subnodes, similar to "close_all_nodes" + + expand_all_nodes: Expand all directory nodes in the tree recursively. + +P = toggle_preview: Toggles "preview mode", see |neo-tree-preview-mode| + +l = focus_preview: Focus the active preview window + + = revert_preview: Ends "preview_mode" if it is enabled, and reverts + any preview windows to what was being shown before + preview mode began. + +S = open_split: Same as open, but opens in a new horizontal split. + +s = open_vsplit: Same as open, but opens in a vertical split. + +t = open_tabnew: Same as open, but opens in a new tab. + + open_drop: Same as open, but opens with the |:drop| command. + + open_tab_drop: Same as open, but opens in a new tab with the + |:drop| command with the |:tab| modifier. + +w = open_with_window_picker: Uses the `window-picker` plugin to select a window + to open the selected node in. Requires that + https://github.com/s1n7ax/nvim-window-picker + be installed. + + split_with_window_picker: Same as `open_with_window_picker` but opens split + in selected node instead. + + vsplit_with_window_picker: Same as `open_with_window_picker` but opens + vertical split in selected node instead. + +[g = prev_git_modified: Jump to the previous file reported by `git status` + that is within the current working directory. + This will loop around if you are on the last one. + +]g = next_git_modified: Jump to the next file reported by `git status` + that is within the current working directory. + This will loop around if you are on the last one. + + +FILE ACTIONS *neo-tree-file-actions* +a = add: Create a new file OR directory. Add a `/` to the + end of the name to make a directory. This command + supports an optional `config.show_path` option + which controls what portion of the path is shown + in the prompt. The choices for this option are: + + `"none"`: which is the default. + `"relative"`: shows the portion which is relative + to the current root of the tree. + `"absolute"`: is the full path to the current + directory. + + The file path also supports BASH style brace + expansion. sequence style ("{00..05..2}") as well + as nested braces. Here are some examples how this + expansion works. + + "x{a..e..2}" : "xa", "xc", "xe" + "file.txt{,.bak}" : "file.txt", "file.txt.bak" + "./{a,b}/{00..02}.lua" : "./a/00.lua", "./a/01.lua", + "./a/02.lua", "./b/00.lua", + "./b/01.lua", "./b/02.lua" + +A = add_directory: Create a new directory, in this mode it does not + need to end with a `/`. The path also supports + BASH style brace expansion as explained in `add` + command. Also accepts `config.show_path` options + +d = delete: Delete the selected file or directory. + Supports visual selection.~ + +r = rename: Rename the selected file or directory. + +y = copy_to_clipboard: Mark file to be copied. + Supports visual selection.~ + +x = cut_to_clipboard: Mark file to be cut (moved). + Supports visual selection.~ + +p = paste_from_clipboard: Copy/move each marked file to the selected folder. + +c = copy: Copy the selected file or directory. + Also accepts the optional `config.show_path` option + like the add file action. + +m = move: Move the selected file or directory. + Also accepts the optional `config.show_path` option + like the add file action. + + +VIEW CHANGES *neo-tree-view-changes* +H = toggle_hidden: Toggle whether hidden (filtered items) are shown or not. + +R = refresh: Rescan the filesystem and redraw the tree. Changes made + within nvim should be detected automatically, but this is + useful for changes made elsewhere. + + +FILTER *neo-tree-filter* + +NOTE: All of the below commands are affected by the `find_by_full_path_words` +option: +>lua + require("neo-tree").setup({ + filesystem = { + find_by_full_path_words = false, + } + }) +< +`false` means it only searches the tail of a path and is the default. +`true` will change the filter into a full path search with space as an implicit +`".*"`, so `fi init` will match: `./sources/filesystem/init.lua` + + +/ = fuzzy_finder: Filter the tree recursively, searching for + files and folders that contain the specified term as + you type. This will use fd if it is installed, or + find, or which if you are on Windows. + + As of v1.28, this acts like a fuzzy finder, + meaning that pressing up/down while the filter + window is open will move the cursor up and down in + the tree, and pressing `` will open that + item and clear the filter. Any other method of + closing the filter window will also clear the + filter. + +D = fuzzy_finder_directory: Like fuzzy_finder above, but only shows directories. + Pressing on a directory will clear the + search and set the focus on that directory. + Requires `fd` or `find` to be installed~ + +# = fuzzy_sorter: Sort the tree recursively based on fzy algorithm, + showing top score files. Space separated keywords + are treated as `and` which will be useful to narrow + down as you type. The file list is taken from fd + and other programs mentioned in `fuzzy_finder`. + `fuzzy_sorter_directory` can be used to show list + of directories instead. + +f = filter_on_submit: Same as above, but does not search until you hit + enter. Useful if filter_as_you_type is too slow. + Also useful if you want to leave the tree + filtered. + + = clear_filter: Removes the filter. + +PREVIEW MODE *neo-tree-preview-mode* + +Preview mode will temporarily show whatever file the cursor is on without +switching focus from the Neo-tree window. By default, files will be previewed +in a new floating window. This can also be configured to automatically choose +an existing split by configuring the command like this: + +>lua + require("neo-tree").setup({ + window = { + mappings = { + ["P"] = { "toggle_preview", config = { use_float = false } }, + } + } + }) +< +Anything that causes Neo-tree to lose focus will end preview mode. When +`use_float = false`, the window that was taken over by preview mode will revert +back to whatever was shown in that window before preview mode began. + +If you want to work with the floating preview mode window in autocmds or other +custom code, the window will have the `neo-tree-preview` filetype. + +When preview mode is not using floats, the window will have the window local +variable `neo_tree_preview` set to `1` to indicate that it is being used as a +preview window. You can refer to this in statusline and winbar configs to mark a +window as being used as a preview. + + +CUSTOM MAPPINGS *neo-tree-custom-mappings* + +If you want to change the mappings, you can do so in two places. Mappings +defined in `window.mappings` apply to all sources, and mappings defined at the +source level, such as `filesystem.window.mappings` will override and extend +those global mappings for that particular source. + +For example: +>lua + require("neo-tree").setup({ + window = { + mappings = { + ["A"] = "command_a" + } + }, + filesystem = { + window = { + mappings = { + ["A"] = "command_b" + } + } + } + }) +< +The above config will map `A` to command_a for all sources except for +filesystem, which will use command_b instead. + +If you don't want to use *any* default mappings, you can set +`use_default_mappings = false` in your config. + +If you want to remove one or more particular default mappings, you can map +the sequence to `none` or `noop`. For example, if you don’t wish to use +fuzzy finder (default mapping `/`), but instead rely on Neovim’s built-in +search functionality, you can do that like so this: + +>lua + require("neo-tree").setup({ + filesystem = { + window = { + mappings = { + -- disable fuzzy finder + ["/"] = "noop" + } + } + } + }) +< + +NOTE: Not all commands work for all sources. If it is defined in the source +section in the default config instead of at the root level, that means it is +specific to that source and will not work for others. + + +CUSTOM COMMANDS *neo-tree-custom-commands* + +If you want to define your own command, you have two options: + 1. You can define (or override) a command in the `commands` section of the + config for each source, then reference that by name in a mapping. + 2. You can map directly to a function and skip defining a command. + +You probably want #2: +>lua + require("neo-tree").setup({ + filesystem = { + window = { + mappings = { + ["?"] = function(state) + local node = state.tree:get_node() + print(node.name) + end + } + } + } + }) +< +..or +>lua + local print_me = function(state) + local node = state.tree:get_node() + print(node.name) + end + + require("neo-tree").setup({ + filesystem = { + window = { + mappings = { + ["?"] = print_me + } + } + } + }) +< +...but if you want #1, here is how that works: + +>lua + require("neo-tree").setup({ + filesystem = { + commands = { + print_me = function(state) + local node = state.tree:get_node() + print(node.name) + end + }, + mappings = { + ["?"] = "print_me" + } + } + }) +< + +GLOBAL CUSTOM COMMANDS *neo-tree-custom-commands-global* + +You can also have global custom commands that will be added to all available +sources. If you need it you can then override it in a specific source. +>lua + require("neo-tree").setup({ + commands = { + hello = function() -- define a global "hello world" function + print("Hello world") + end + }, + + window = { + mappings = { + [""] = "hello" + -- define a global mapping to call 'hello' in every source + } + }, + + filesystem = { + commands = { + -- override implementation of the 'hello' action in filesystem source + hello = function() + print("Hello inside filesystem") + end + } + } + }) +< +Now when pressing `` in 'buffers' or 'git_status' it will print "Hello world", +but in 'filesystem' it will print "Hello inside filesystem". + +CUSTOM MAPPINGS WITH VISUAL MODE + +If you want to create a mapping that supports visual mode, the way to do that +is to add a second command where the name is the same as the normal mode +command, but with `_visual` added to the end. Any mapping for this command will +then work in either normal or visual mode. + +The `_visual` version of the command will be called with a second argument +which is a list of the nodes that were selected when the command was called. + +For example, this is how the built-in `delete` command is defined: + +>lua + M.delete = function(state, callback) + local tree = state.tree + local node = tree:get_node() + fs_actions.delete_node(node.path, callback) + end + + M.delete_visual = function(state, selected_nodes, callback) + local paths_to_delete = {} + for _, node_to_delete in pairs(selected_nodes) do + table.insert(paths_to_delete, node_to_delete.path) + end + fs_actions.delete_nodes(paths_to_delete, callback) + end +< + +CUSTOM MAPPINGS WITH ARGUMENTS + +If you want to include options for your mappings, such as `nowait`, you can +set this for all mappings using the `mapping_options` key, or on individual +mappings by specifying them as a table that consists of the command and any +options you want to use. If both are specified, the mapping merges with and +overrides the global `mapping_options` + +The command can be either the string name of a built-in command, or a +function, and is specified either as the first element in the table or by +assigning it to the `command` key: +>lua + require("neo-tree").setup({ + filesystem = { + window = { + mapping_options = { + noremap = true, + nowait = false, + }, + mappings = { + ["?"] = { + function(state) + local node = state.tree:get_node() + print(node.name) + end, + nowait = true + }, + ["i"] = { + command = function(state) + local node = state.tree:get_node() + print(node.name) + end, + nowait = true + }, + ["o"] = { + command = "open", + nowait = true + }, + ["O"] = { + "open", + nowait = true + }, + } + } + } + }) +< +See |:map-arguments| for possible values to include. "buffer" and "nnoremap" +are enabled by default. + +CUSTOM MAPPINGS WITH CONFIG + +Some mappings may accept an optional `config` table to control it's behavior. +When that is the case, the command is specified using the table syntax, and +the config options are in a table bound to the `config` key: +>lua + require("neo-tree").setup({ + filesystem = { + window = { + mappings = { + ["a"] = { + "add", + nowait = true + config = { + show_path = "none" -- "none", "relative", "absolute" + } + }, + } + } + } + }) +< +When the `config` key is used, it is added to the `state` argument that is +passed to the command function: +>lua + M.add = function(state, callback) + local show_path = state.config.show_path + ... +< + +================================================================================ +CONFIGURATION ~ +================================================================================ + *neo-tree-configuration* +Neo-tree is highly configurable and you should be able to make it do whatever +you want without having to change the internal code. Here are the ways you can +customize it: + +By setting config options in the |neo-tree-setup| function. This is for very +common items and is how you would configure most lua plugins. You can also +change the look by configuring the appropriate highlight groups, see +|neo-tree-highlights|. + +By creating custom mappings (see |neo-tree-mappings|). You can of course just +change what keys are mapped to which built-in functions, but you can also map +keys to a custom function and do whatever you want. See the wiki for some +examples: https://github.com/nvim-neo-tree/neo-tree.nvim/wiki/Recipes#commands + +By hooking into |neo-tree-events|. You can do things like always clear the +search after opening a file, or define a custom file opener to choose what +window will be used, or respond to file events like renames and moves. + +By configuring, rearranging, adding, or removing |neo-tree-renderers| for each +node type. The renderer is a list of components, such as "icon" and "name", +which determines how each node displayed. Use them as lego pieces to build what +you want to see. + +By adding or replacing |neo-tree-components|. Components are the functions +called by the renderers, and they return the text and highlight group to be +displayed. If you want to gather extra data just once per render to be used by a +custom component, you can do so in the "before_render" event (see +|neo-tree-events|), set that data on the `state` object, and reference it in the +component. See the wiki for some examples of custom components: +https://github.com/nvim-neo-tree/neo-tree.nvim/wiki/Recipes#components + + +SETUP *neo-tree-setup* + +To override the defaults or add new functionality, call the setup() function +with your overrides. For example, to add your own mappings in 'lua': + +>lua + require("neo-tree").setup({ + filesystem = { + window = { + mappings = { + [""] = "refresh", + ["o"] = "open", + } + } + } + }) +< + +NOTE: The mappings you define will be merged with the default mappings. If you +wish to remove a default mapping without overriding it with your own function, +assign it the the string "none". This will cause it to be skipped and allow any +existing global mappings to work. + +NOTE: SOME OPTIONS ARE ONLY DOCUMENTED IN THE DEFAULT CONFIG!~ +Run `:lua require("neo-tree").paste_default_config()` to dump the fully +commented default config in your current file. Even if you don't want to use +that config as your starting point, you still may want to dump it to a blank +lua file just to read it as documentation. + + +SOURCE SELECTOR *neo-tree-source-selector* + +You can enable a clickable source selector in either the winbar +(requires neovim 0.8+) or the statusline. To do so, set one of these options +to `true`: + +>lua + requires("neo-tree").setup({ + source_selector = { + winbar = false, + statusline = false + } + }) +< + +The configuration options for source selector are all placed inside +`source_selector` table. Below are the options with their default values and +type notations. + +>lua + requires("neo-tree").setup({ + source_selector = { + winbar = false, -- toggle to show selector on winbar + statusline = false, -- toggle to show selector on statusline + show_scrolled_off_parent_node = false, -- boolean + sources = { -- table + { + source = "filesystem", -- string + display_name = "  Files " -- string | nil + }, + { + source = "buffers", -- string + display_name = "  Buffers " -- string | nil + }, + { + source = "git_status", -- string + display_name = "  Git " -- string | nil + }, + }, + content_layout = "start", -- string + tabs_layout = "equal", -- string + truncation_character = "…", -- string + tabs_min_width = nil, -- int | nil + tabs_max_width = nil, -- int | nil + padding = 0, -- int | { left: int, right: int } + separator = { left = "▏", right= "▕" }, -- string | { left: string, right: string, override: string | nil } + separator_active = nil, -- string | { left: string, right: string, override: string | nil } | nil + show_separator_on_edge = false, -- boolean + highlight_tab = "NeoTreeTabInactive", -- string + highlight_tab_active = "NeoTreeTabActive", -- string + highlight_background = "NeoTreeTabInactive", -- string + highlight_separator = "NeoTreeTabSeparatorInactive", -- string + highlight_separator_active = "NeoTreeTabSeparatorActive", -- string + }, + }) +< + +Keywords: > + + ┃/ a \/ b \/ c \ ┃ <- edge of window + ^ ^^ ^^ ^ separators + left separator right separator +< + +Configuration Options: +When `show_scrolled_off_parent_node` is `true`, tabs are replaced with the parent +path of the top visible node when scrolled down. + +`sources` is a table to configure the contents of the bar. Previously known +as `tab_labels`. Sources should be a table of tables. Each table inside +`sources` must contain a `source` key, which will refer to the "name" of the +source to add to the bar. A second optional `display_name` key can be +provided to modify how you wish that source to appear in the bar. It is +safe to add sources that do not exist, they will simply be omitted from +the bar if they cannot be found. +NOTE: If `source_selector` is enabled (via `winbar=true` or `statusline=true`) +then the `default_source` will be updated to be the first entry of +`sources`. + +`content_layout` defines how the labels are placed inside a tab. This only takes +effect when the tab width is greater than the length of label i.e. +`tabs_layout = "equal", "focus"` or when `tabs_min_width` is large enough. +Following options are available. + 'start' : left aligned / 裡 bufname \/.. + 'end' : right aligned / 裡 bufname \/... + 'center' : centered with equal padding / 裡 bufname \/... + +`tabs_layout` defines how the tabs are aligned inside the window when there is +more than enough space. The following options are available. `active` will +expand the focused tab as much as possible. Bars denote the edge of window. + 'start' : left aligned ┃/ ~ \/ ~ \/ ~ \ ┃ + 'end' : right aligned ┃ / ~ \/ ~ \/ ~ \┃ + 'center' : centered with equal padding ┃ / ~ \/ ~ \/ ~ \ ┃ + 'equal' : expand all tabs equally to fit the window width ┃/ ~ \/ ~ \/ ~ \┃ + 'active' : expand the focused tab to fit the window width ┃/ focused tab \/ ~ \/ ~ \┃ + +`padding` defines the global padding of the source selector. It can be an +integer or a table with keys `left` and `right`. Setting `padding = 2` is +exactly the same as `{ left = 2, right = 2 }` + +`separator` and `separator_active` take string or table to define the separators +surrounding non-active and active tab respectively. When `separator_active` is +`nil`, it falls back to `separator`. They require three keys `left`, `right`, +and `override` which define how the separators of neighboring tabs are merged +together. The following four options are available for `override`. The examples +show the result of `{ left = "/", right = "\", override = ... }` + 'nil' : never merged / ~ \/ ~ \/ ~ \... + '"right"' : all merged to the right / ~ \ ~ \ ~ \... + '"left"' : all merged to the left / ~ / ~ / ~ /... + '"active"' : merged towards the active tab / ~ / focused tab \ ~ \... +When set to string such as "┃", it is equivalent to +`{ left = "┃", right = "┃", override = "active" }`. + +`show_separator_on_edge` takes a boolean value where `false` (default) hides the +separators on the far left / right. Especially useful when left and right +separator are the same. + 'true' : ┃/ ~ \/ ~ \/ ~ \┃ + 'false' : ┃ ~ \/ ~ \/ ~ ┃ + + +CURRENT WORKING DIRECTORY *neo-tree-cwd* + +By default, Neo-tree will maintain a two-way binding between the cwd of nvim and +the root of the tree. Changing the root in Neo-tree will change the working +directory of nvim and vice versa. + +In the case of a sidebar, this will be synced with the tab working directory +(|tcd|). If you open it in the "current" position, aka netrw style, it will sync +with the window local working directory (|lcd|). + +These defaults can be changed by setting the following options: + +>lua + require("neo-tree").setup({ + filesystem = { + bind_to_cwd = true, -- true creates a 2-way binding between vim's cwd and neo-tree's root + cwd_target = { + sidebar = "tab", -- sidebar is when position = left or right + current = "window" -- current is when position = current + }, + } + }) +< + +In addition to `"tab"` and `"window"`, you can also set the target to `"global"` +for either option, which is the same as using the |cd| command. Setting the target +to `"none"` will prevent neo-tree from setting vim's cwd for that position. + + +FILTERED ITEMS *neo-tree-filtered-items* + +The `filesystem` source has a `filtered_items` section in it's config that +allows you to specify what files and folders should be hidden. By default, any +item identified by these filters will not be visible, but that visibility can +be toggled on and off with a command. Each type of filter has a corresponding +highlight group which will be applied when they are visible, see +|neo-tree-highlights| for details. The following options are available: + +>lua + require("neo-tree").setup({ + filesystem = { + filtered_items = { + visible = false, -- when true, they will just be displayed differently than normal items + hide_dotfiles = true, + hide_gitignored = true, + hide_hidden = true, -- only works on Windows for hidden files/directories + hide_by_name = { + ".DS_Store", + "thumbs.db", + --"node_modules", + }, + hide_by_pattern = { + --"*.meta", + --"*/src/*/tsconfig.json", + }, + always_show = { -- remains visible even if other settings would normally hide it + --".gitignored", + }, + never_show = { -- remains hidden even if visible is toggled to true, this overrides always_show + --".DS_Store", + --"thumbs.db", + }, + never_show_by_pattern = { -- uses glob style patterns + --".null-ls_*", + }, + }, + } + }) +< + +The `visible` option just defines the default value. This value is toggled by +the "toggle_hidden" command, which is mapped to H by default. + +The `hide_dotfiles` option just hides anything that starts with `. `(period). + +The `hide_gitignored` option will query git for the files and folders being +shown, and hide those that are marked as ignored. + +The `hide_hidden` option only will work on Windows using the Windows logic +that determines if a file or directory is hidden. + +The `hide_by_name` option is a list of file/folder names that should be +hidden. This is an exact match. + +The `hide_by_pattern` option uses glob syntax, which is converted to lua +patterns. No guarantees on how it handles advanced patterns. + +The `always_show` option is a list of file/folder names that will always be +visible, even if other settings would normally hide it. This section takes +precedence over all other options except for `never_show`. + +The `never_show` option is the same as `hide_by_name`, except that those items +will remain hidden even if you toggle `visible` to true. This section takes +precedence over the others. + +The `never_show_by_pattern` option is the same as `hide_by_pattern`, except that +those items will remain hidden even if you toggle `visible` to true. This +section takes precedence over the others. + + +NETRW HIJACK BEHAVIOR *neo-tree-netrw-hijack* + +Neo-tree can and does hijack Netrw by default. This is configurable and can be +disabled if you use Netrw, or have other plugins that use Netrw functionality. +This can be controlled by setting the `filesystem.hijack_netrw_behavior` option +to one of: + +disabled Netrw left alone, neo-tree does not handle opening dirs. + +open_default (default) Netrw disabled, opening a directory opens neo-tree + in whatever position is specified in `window.position`. + +open_current Netrw disabled, opening a directory opens within the + window like netrw would, regardless of `window.position`. + +>lua + require("neo-tree").setup({ + filesystem = { + hijack_netrw_behavior = "open_default", + -- "open_current", + -- "disabled", + }) +< + + + + +COMPONENT CONFIGS *neo-tree-component-configs* + +The visual display of a node is made up of a series of components rendered in a +certain order and with certain configuration options. See |neo-tree-components| +for a deeper dive into customizing this aspect. If you wish to configure those +components in a universal way, the best place to do that is in the +`default_component_configs` section of the config. + +For example, to add indent markers, you can apply your settings in each renderer +for each source, or just do it once in the default_component_configs section: + +>lua + require("neo-tree").setup({ + default_component_configs = { + indent = { + with_markers = true, + indent_marker = "│", + last_indent_marker = "└", + indent_size = 2, + }, + }, + }) +< +See |neo-tree-indent-markers| for more details. + +The default config has more examples of component configuration, use +|NeoTreePasteConfig| to view that default config. + + +GIT STATUS *neo-tree-git-status* + +By default, Neo-tree will attempt to get the git status for files in the +current directory. It will use this information to add markers to the right of +your files, and will set the highlight groups of files and directories. + +To disable this feature entirely, set `enable_git_status = false` in your +config when calling the setup function. To just disable colors on file or +directory names, you can set `use_git_status_colors = false` in the `name` +component of your renderer(s). + +Starting with 2.0, this will display symbols by default. The default symbols +will require a nerd font to be installed. To change these symbols, you can set +the following properties: +>lua + require("neo-tree").setup({ + default_component_configs = { + symbols = { + -- Change type + added = "✚", + deleted = "✖", + modified = "", + renamed = "", + -- Status type + untracked = "", + ignored = "", + unstaged = "", + staged = "", + conflict = "", + } + } + }) +< +To change the color of these symbols, you can edit the corresponding highlight +groups: + + NeoTreeGitAdded + NeoTreeGitConflict + NeoTreeGitDeleted + NeoTreeGitIgnored + NeoTreeGitModified + NeoTreeGitUntracked + +If you'd like to disable certain symbols, you can set them to an empty string. +For example, it is actually redundant to show the change type if you use the +default behavior of highlighting the file name according to the change type. +The following config will remove those change type symbols: +>lua + require("neo-tree").setup({ + default_component_configs = { + symbols = { + -- Change type + added = "", + deleted = "", + modified = "", + renamed = "", + -- Status type + untracked = "", + ignored = "", + unstaged = "", + staged = "", + conflict = "", + } + } + }) +< + +To revert to the previous behavior of passing the git status through as-is +with codes like `[M ]` for changed/unstaged, and `[ M]` for changed/staged, +you can set the `symbols` property to nil or false: +>lua + require("neo-tree").setup({ + default_component_configs = { + git_status = { + symbols = false + } + } + }) +< +See also: |neo-tree-git-status-source| + + +DIAGNOSTICS *neo-tree-diagnostics* + +By default, Neo-tree will display diagnostic symbols next to files. It will +display the highest severity level for files, and errors only for directories. +If you want to use symbols instead of "E", "W", "I", and H", you'll need to +define those somewhere in your nvim configuration. Here is an example: + +>lua + vim.fn.sign_define("LspDiagnosticsSignError", + {text = " ", texthl = "LspDiagnosticsSignError"}) + vim.fn.sign_define("LspDiagnosticsSignWarning", + {text = " ", texthl = "LspDiagnosticsSignWarning"}) + vim.fn.sign_define("LspDiagnosticsSignInformation", + {text = " ", texthl = "LspDiagnosticsSignInformation"}) + vim.fn.sign_define("LspDiagnosticsSignHint", + {text = "", texthl = "LspDiagnosticsSignHint"}) +< + +Alternatively, you can also specify the signs and/or highlights in the +neo-tree setup call like this: + +>lua + require("neo-tree").setup({ + default_component_configs = { + diagnostics = { + symbols = { + hint = "H", + info = "I", + warn = "!", + error = "X", + }, + highlights = { + hint = "DiagnosticSignHint", + info = "DiagnosticSignInfo", + warn = "DiagnosticSignWarn", + error = "DiagnosticSignError", + }, + }, + } + }) +> +Anything not specified in the `default_component_configs` will fallback to the +`sign_define` method. + +To disable this feature entirely, set `enable_diagnostics = false` in your +config when calling the setup function. + + +INDENT MARKERS *neo-tree-indent-markers* + +By default, indent markers (aka indent guides) are disabled. In Neo-tree +indent is a component, so to enable indent markers, you need configure the +`indent` component: + +...at the global level: +>lua + require("neo-tree").setup({ + default_component_configs = { + indent = { + with_markers = true, + indent_marker = "│", + last_indent_marker = "└", + indent_size = 2, + }, + }, + }) +< + +...or in each renderer: +>lua + require("neo-tree").setup({ + filesystem = { + renderers = { + directory = { + { + "indent", + with_markers = true, + indent_marker = "│", + last_indent_marker = "└", + indent_size = 2, + }, + -- other components + }, + file = { + { + "indent", + with_markers = true, + indent_marker = "│", + last_indent_marker = "└", + indent_size = 2, + }, + -- other components + }, + } + } + }) +< + +You also can change the marker characters. To do this, you need change +`indent_marker` and `last_indent_marker` settings. + +To change highlight of indent markers, you need configure `NeoTreeIndentMarker` +highlight group. By default, it refers to `Normal` highlight. + + +EXPANDERS *neo-tree-expanders* +Is hightly recommended enable if file nesting is enabled (this is the default +behavior if `with_expanders` is nil). The config can be done inside the `indent` +component: +>lua + require("neo-tree").setup({ + default_component_configs = { + indent = { + with_expanders = true, + expander_collapsed = "", + expander_expanded = "", + expander_highlight = "NeoTreeExpander", + }, + }, + }) +< + +FILE NESTING *neo-tree-file-nesting* + +By default, file nesting is disabled since the `nesting_rules` table is empty. +To enable this feature, fill in the `nesting_rules` table with the following +structure: +>lua + require("neo-tree").setup({ + nesting_rules = { + ["js"] = { "js.map" }, + } + }) +< +This will render: +> + FILENAME.js + FILENAME.js.map + +< +The default mapping to expand/collapse nested files is . + + +HIGHLIGHTS *neo-tree-highlights* + +The following highlight groups are defined by this plugin. If you set any of +these yourself before the plugin loads, it will not be touched. If they do not +exist, they will be created. + +NeoTreeBufferNumber The buffer number shown in the buffers source. +NeoTreeCursorLine |hl-CursorLine| override in Neo-tree window. +NeoTreeDimText Greyed out text used in various places. +NeoTreeDirectoryIcon Directory icon. +NeoTreeDirectoryName Directory name. +NeoTreeDotfile Used for icons and names when dotfiles are filtered. +NeoTreeFileIcon File icon, when not overridden by devicons. +NeoTreeFileName File name, when not overwritten by another status. +NeoTreeFileNameOpened File name when the file is open. Not used yet. +NeoTreeFilterTerm The filter term, as displayed in the root node. +NeoTreeFloatBorder The border for pop-up windows. +NeoTreeFloatTitle Used for the title text of pop-ups when the border-style + is set to another style than "NC". This is derived + from NeoTreeFloatBorder. +NeoTreeTitleBar Used for the title bar of pop-ups, when the border-style + is set to "NC". This is derived from NeoTreeFloatBorder. +NeoTreeGitAdded File name when the git status is added. +NeoTreeGitConflict File name when the git status is conflict. +NeoTreeGitDeleted File name when the git status is deleted. +NeoTreeGitIgnored File name when the git status is ignored. +NeoTreeGitModified File name when the git status is modified. +NeoTreeGitUnstaged Used for git unstaged symbol. +NeoTreeGitUntracked File name when the git status is untracked. +NeoTreeGitStaged Used for git staged symbol. +NeoTreeHiddenByName Used for icons and names when `hide_by_name` is used. +NeoTreeIndentMarker The style of indentation markers (guides). By default, + the "Normal" highlight is used. +NeoTreeExpander Used for collapsed/expanded icons. +NeoTreeNormal |hl-Normal| override in Neo-tree window. +NeoTreeNormalNC |hl-NormalNC| override in Neo-tree window. +NeoTreeSignColumn |hl-SignColumn| override in Neo-tree window. +NeoTreeStatusLine |hl-StatusLine| override in Neo-tree window. +NeoTreeStatusLineNC |hl-StatusLineNC| override in Neo-tree window. +NeoTreeVertSplit |hl-VertSplit| override in Neo-tree window. +NeoTreeWinSeparator |hl-WinSeparator| override in Neo-tree window. +NeoTreeEndOfBuffer |hl-EndOfBuffer| override in Neo-tree window. +NeoTreeRootName The name of the root node. +NeoTreeSymbolicLinkTarget Symbolic link target. +NeoTreeTitleBar Used for the title bar of pop-ups, when the border-style + is set to "NC". This is derived from NeoTreeFloatBorder. +NeoTreeWindowsHidden Used for icons and names that are hidden on Windows. + + +EVENTS *neo-tree-events* + +Events are one way to customize the behavior of Neo-tree. You can add event +handlers to your config in the `event_handlers` section, which should be a list +of objects in the form: + +>lua + { + event = "event_name", + handler = function(arg) + -- do something, the value of arg varies by event. + end, + id = "optional unique id, only meaningful if you want to unsubscribe later" + } +< + +The following events are available: + +"before_render"~ +Fired after items have been collected from the source but before drawing the +nodes of the tree. This is the best place to gather additional data to be used +by components. The argument passed is the state of the source, which is also +passed to components and commands down the line. + +"after_render"~ +Fired after the tree has been rendered. The argument passed is the state of the +source, which is also passed to components and commands down the line. + +"file_added"~ +Fired after a file (or folder) has been created, either by using the "add" +command or by copy and paste. The arg is the full path to the new file. + +"file_deleted"~ +Fired after a file (or folder) has been deleted. The arg is the full path to the +deleted file. + +"file_moved"~ +Fired after a file (or folder) has been moved. The arg is a table containing +`source` and `destination` properties. + +"file_open_requested"~ +Fired just before a file is opened. The arg is a table containing the `state` +of the source being used, the `path` of the file to be opened, and `open_cmd`, +which is the open command that was requested. `open_cmd` will be either |edit|, +|split|, |vsplit|, |tabnew|. This function should return a table with a property called +`handled` which is true if the file open operation was handled, or false if it +was not. If `{ handled = true }` is not returned, the file will be opened using +the built-in logic. + +"file_opened"~ +Fired after a file has been opened. You might use this to auto-close the window +or clear the filter. The arg is the path of the file opened. + +"file_renamed"~ +Fired after a file (or folder) has been renamed. The arg is an table containing +`source` and `destination` properties. + +"neo_tree_buffer_enter"~ +Fired after entering a neo-tree buffer. It is also right after neo-tree applies +it's own settings, so it's the ideal place to apply any local settings you would +like to have. + +"neo_tree_buffer_leave"~ +Fired after a neo-tree buffer was exited. Technically it fires when entering a +buffer that is not neo-tree, when the last buffer enter event was neo-tree. + +"neo_tree_popup_buffer_enter"~ +Fired after entering a neo-tree popup buffer. This includes things such as file +rename prompts and filter inputs. It runs right after neo-tree applies it's own +settings, so it's the ideal place to apply any local settings you would like to +have. + +"neo_tree_popup_buffer_leave"~ +Fired after leaving a neo-tree popup buffer. + +"neo_tree_window_before_open"~ +Fired before opening a new Neo-tree window. Called with the following arg: + *neo-tree-window-event-args* +The event argument for all window events is a table with the following keys: + `winid` = the |winid| of the window being opened or closed. + `tabid` = id of the tab that the window is in. + `tabnr` = (deprecated) number of the tab that the window is in. + `source` = the name of the source that is in the window, such as "filesystem". + `position` = the position of the window, i.e. "left", "bottom", "right". + +"neo_tree_window_after_open"~ +Fired after opening a new Neo-tree window. Called with +|neo-tree-window-event-args|. + +"neo_tree_window_before_close"~ +Fired before closing a Neo-tree window. Called with +|neo-tree-window-event-args|. + +"neo_tree_window_after_close"~ +Fired after closing a Neo-tree window. Called with +|neo-tree-window-event-args|. + +NOTE: The following events are used internally and not intended for end user +usage. You can use them if you want, but beware that they may be debounced, and +the details of how frequently they are fired and what events are dropped will be +changed without warning. + +"vim_diagnostic_changed"~ +Fired on the |DiagnosticChanged| autocmd event. The arg is a table with one +property: `diagnostics_lookup`, which is a table where the keys are file names +and the values are tables with diagnostic counts by severity level. + +"vim_buffer_changed"~ +Fired on the following autocmd events: |BufDelete|, |BufWritePost|, +|BufFilePost|, |BufNew| + +"vim_buffer_enter"~ +Fired on the following autocmd events: |BufEnter|, |BufWinEnter| + +"vim_dir_changed"~ +Fired on the |DirChanged| autocmd event + +"vim_win_enter"~ +Fired on the |WinEnter| autocmd event + +"vim_colorscheme"~ +Fired on the |ColorScheme| autocmd event + + +You can also define your own with: +> +>lua + require("neo-tree.events.queue").define_event(event_name, { + setup = , + seed = , + teardown = , + debounce_frequency = , + once = , + cancelled = + }) +< + +The setup function is run the first time the event is subscribed to. For an +autocmd event, this would define the vim autocmd to connect it to fire_event(). + +The `seed` function is run at the beginning of every event firing. The diagnostics +event uses this to collect the diagnostic information and pass it to all +subscribers. + +The `teardown` function is used when the last subscriber unsubscribes, and cleans +up. This is like Dispose in other languages. + +`debounce_frequency` is the minimum number of milliseconds between each invocation +of the event. The first event is guaranteed to fire, as well as the last one, but +in between events may be dropped if this is set to a number greater than zero. + +`once` means to only fire this event handler once then mark it as `cancelled`. + +`cancelled` means that this event handler will be skipped in all future event +fires, and will be discarded on the next cleanup of the queue. + + +COMPONENTS AND RENDERERS *neo-tree-renderers* + +A renderer is just a list of component configs, to be rendered in order to +create a line in the tree. Each renderer is for a specific node type, such as +`directory` or `file`. To view the available built-in components and their +configs for each source, look at the default config by pasting it with +>vim + :lua require("neo-tree").paste_default_config() +< +or view it online at: +https://github.com/nvim-neo-tree/neo-tree.nvim/blob/v1.x/lua/neo-tree/defaults.lua + +A default `renderers` config is specified at the root level and will be used +by each source unless another renderer is defined. If you just want to +rearrange or remove components, you can do so by changing these `renderers` +configs. + + *neo-tree-components* +A component is a function that returns a single text object: +>lua + { + text = "Node A", + highlight = "Normal" + } +< + +... or a list of text objects: +>lua + { + { + text = "Node Name", + highlight = "Directory" + }, + { + text = "[", + highlight = "Comment" + }, + { + text = "I'm Special!", + highlight = "SpecialChar" + }, + text = "[", + highlight = "Comment" + } + } +< + +The only reason to return a list of objects is to use multiple highlight groups. +These components and renderers are defined per source by passing them in the +setup. If you define a component with the same name of a built-in component, it +will replace that built-in component. Otherwise it will be added to the existing +set of components. + +CONTAINER *neo-tree-container* + +One unique component that deserves some explanation is the `container` +component. This component allows you to create more complex layouts where +components can overlap, have a specific size, or be right aligned. A container +has the following properties: + +width~ +Width can be specified as a number, meaning actual number of characters, a +string containing a percentage such as `"100%"`, or the special string +`"fit_content"`. The percentage value means percentage of remaining space in +the window. If a window is 40 columns wide, and the rendered content for the +node so far equals 15 characters, then 100% would evaluate to 25 characters. + +The `"fit_content"` value means that it will be the width of the largest +layer. See `zindex` for details about layers. + +If the current position is "current", meaning it is being displayed in a split +instead of as a sidebar, the available width will be calculated as the longest +node name + indent + 8 characters. This is to prevent right aligned components +from being too far away from the node name. + +min_width / max_width~ +This constrains the value of width, useful when the `width` is set to a +percentage or `"fit_content"`. + +content~ +This is a list of components that will be arranged by this container. + +Each component in the content list can use these additional properties: + + zindex~ + All components with the same zindex will be rendered together in the same + layer, one after the other. Higher zindex value are rendered on top of other + layers, hiding whatever is beneath them. For example, if a component with a + zindex of 10 produces this: +> + "abcdefg" +< + and another component width a zindex of 20 produces this: +> + "1234" +< + then the result will be: +> + "1234efg" +< + + align~ + If align is right, then it will be pushed to the right edge of the available + space. This makes the most sense when the container width is set to a number + or `"100%"`. Components that are right aligned will automatically overlap left + aligned components with the same zindex if there is not enough space. + + Continuing with the example from above, if there was a `"right"` aligned + component with a zindex of 20 that outputs: +> + "**"" +< + Then the result when a container has a width of 12 would be: +> + "1234efg **" +< + but if the width was 8 then the result would be: +> + "1234ef**" +< + +Example~ + +This example container has the name on the left hand side, and the +diagnostics and git status aligned to the right hand side of the window. The +diagnostics and git_status will always be shown, and the name will be clipped if +there is not enough space: +>lua + { + "container", + width = "100%", + right_padding = 1, + content = { + { + "name", + use_git_status_colors = true, + zindex = 10 + }, + { "diagnostics", zindex = 20, align = "right" }, + { "git_status", zindex = 20, align = "right" }, + }, + } +< + + +CUSTOM COMPONENTS + +Each component function is called with the following args: + `config` The config object defined in the renderer. This is how a component + can be made to be configurable. This is useful if you want different behavior + in a directory renderer vs a file renderer. The config is a combination of any + options specified in the default_component_configs + (|neo-tree-default-component-configs|), which can be overridden by settings + specified within each renderer config. + + `node` The NuiNode object for this node. The properties can vary by source, but + each one will generally have at least id and name properties. + + `state` This is the state of the plugin. This object is persistent for the + life of the source, with one state object per source per tab. the entirety of + all state and source level configuration is in this one object. Aside from + configuration, it can also hold anything you may want to set in a + "before_render" event. + +For example, here is the simplest possible component: + +>lua + require("neo-tree").setup({ + filesystem = { + components = { + + name = function(config, node) + return { + text = node.name, + highlight = "NeoTreeFileName" + } + end + + } + } + }) +< + +For a more complete example, here is the actual built-in `name` component, which +is much more dynamic and configurable: + +>lua + require("neo-tree").setup({ + filesystem = { + components = { + + name = function(config, node, state) + local highlight = config.highlight or highlights.FILE_NAME + if node.type == "directory" then + highlight = highlights.DIRECTORY_NAME + end + if node:get_depth() == 1 then + highlight = highlights.ROOT_NAME + else + if config.use_git_status_colors == nil or config.use_git_status_colors then + local git_status = state.components.git_status({}, node, state) + if git_status and git_status.highlight then + highlight = git_status.highlight + end + end + end + return { + text = node.name, + highlight = highlight, + } + end + + } + } + }) +< + + +BUFFER VARIABLES *neo-tree-buffer-variables* + +Neo-tree sets certain buffer options and variables that you may use in custom +code or integrations if you need it. The |filetype| of the main window is +`neo-tree`. The buffer will also have these local variables set: + +`winid` The window handle of the window that it was created in. +`tabid` The id of the tab that it was created in. +`tabnr` (deprecated) The number of the tab that it was created in. +`source` The name of the source that created it, i.e. filesystem, buffers, etc. + +Please note that if the buffer is displayed in another window or tab, it's +behavior is unpredictable. It is meant to be locked to it's original location, +which is why those variables are recorded. + + +POPUPS *neo-tree-popups* + +Popups will be created with a |filetype| of `neo-tree-popup`. You can use this +as the target for autocmds or to exclude them from being acted upon by other +plugins. + +They can also be configured by setting the `popup_border_style` in your config, +and the colors of that border are controlled by the `NeoTreeFloatBorder` +highlight group. If you you use the special `NC` option for +`popup_border_style`, the title bar of that popup uses the `NeoTreeTitleBar` +highlight group. + + +================================================================================ +OTHER SOURCES ~ +================================================================================ + *neo-tree-sources* + +Neo-tree supports other sources beside the filesystem source which is used by +default. The rest of the sources follow the same pattern as the filesystem +sources described above. The following sections will give an overview of each +source and describe the options that are unique to those sources. + + +BUFFERS *neo-tree-buffers* + +The buffers source shows all open buffers. This is the same list that |ls| would +show. This view adds one component, which is the buffer number, shown to the +right of the file name by default. + +If you use sessions, your previously loaded buffers may be saved as part of +the session, but they will be unloaded at first. If you want to see these +unloaded buffers, set `show_unloaded = true` in your `buffers` config. +Otherwise, you will only see the buffers that have been opened since starting +nvim. + +As a list of files, this source shares most of the commands with the filesystem +source, with the exception of filtering. Some of these commands make less +sense to use here, as things like adding new files won't be visible until you +open them by some other means. One command that is unique to this view is +`buffer_delete`, which issues |:bdelete| on the selected buffer. This is mapped +to `bd` by default. + + +GIT STATUS *neo-tree-git-status-source* + +The git_status view shows the output of the `git status` command in the tree. +Unlike the other sources, this will always show the project root of the +current working directory. If the working tree is clean, this view will be +empty. + +This view has most file commands except for "add", plus the following git +specific commands: +>lua + ["A"] = "git_add_all", + ["ga"] = "git_add_file", + ["gu"] = "git_unstage_file", + ["gr"] = "git_revert_file", + ["gc"] = "git_commit" + ["gp"] = "git_push", + ["gg"] = "git_commit_and_push", +< + +DOCUMENT SYMBOLS *neo-tree-document-symbols* + +The document_symbols source lists the symbols in the current document obtained +by the LSP request "textDocument/documentSymbols". + +Its configuration includes the following options: + +follow_cursor~ +If set to `true`, will automatically focus on the symbol under the cursor. + +kinds~ +A table specifying how LSP kinds should be rendered. Each entry should map the +LSP kind name to an icon and a highlight group, for example + `Class = { icon = "", hl = "Include" }` + +custom_kinds~ +A table mapping the LSP kind id (an integer) to the LSP kind name that is used +for `kinds`, for example + `[252] = 'TypeAlias'` + +For the list of kinds (id and name), please refer to +https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol + +client_filters~ +This option could be used to set which LSP server is used to obtain the document +symbols. This accepts one of the following values + + `"first"`: use the first LSP server that provides the feature + `"all"`: use all LSP server that provides the feature + `{ fn = function(name), allow_only = table, ignore = table }` where + `fn`: a function that returns `true` if the server `name` should be used + `allow_only`: use only servers from this list + `ignore`: exclude all servers from this list + NOTE: `fn` preceeds `allow_only` preceeds `ignore` + +For example: (NOTE: here only `fn` will be taken into account) +>lua + { + fn = function(name) return name ~= "null-ls" end, + allow_only = { "clangd", "lua_ls" }, + ignore = { "pyright" }, + } +< +Currently, this source supports the following commands: +>lua + ["o"] = "jump_to_symbol", + ["r"] = "rename", + ["P"] = "preview", (and related commands) + ["s"] = "split", (and related commands) +< +vim:tw=80:ts=2:et:ft=help: diff --git a/bundle/neo-tree.nvim/lua/neo-tree.lua b/bundle/neo-tree.nvim/lua/neo-tree.lua new file mode 100644 index 000000000..a4be73157 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree.lua @@ -0,0 +1,257 @@ +local vim = vim +local utils = require("neo-tree.utils") +local renderer = require("neo-tree.ui.renderer") +local log = require("neo-tree.log") +local manager = require("neo-tree.sources.manager") +local setup = require("neo-tree.setup") + +-- If you add a new source, you need to add it to the sources table. +-- Each source should have a defaults module that contains the default values +-- for the source config, and a setup function that takes that config. +local sources = { + "filesystem", + "buffers", + "git_status", +} + +local M = {} + +local check_source = function(source_name) + if not utils.truthy(source_name) then + source_name = M.config.default_source + end + local success, result = pcall(require, "neo-tree.sources." .. source_name) + if not success then + error("Source " .. source_name .. " could not be loaded: ", result) + end + return source_name +end + +local get_position = function(source_name) + local pos = utils.get_value(M, "config." .. source_name .. ".window.position", "left", false) + return pos +end + +M.ensure_config = function() + if not M.config then + M.setup({ log_to_file = false }, true) + end +end + +--DEPRECATED in v2.x +M.close_all_except = function(source_name) + -- this entire function is faulty now that position can be overriden at runtime + source_name = check_source(source_name) + local target_pos = get_position(source_name) + for _, name in ipairs(sources) do + if name ~= source_name then + local pos = utils.get_value(M, "config." .. name .. ".window.position", "left", false) + if pos == target_pos then + manager.close(name) + end + end + end + renderer.close_all_floating_windows() +end + +--DEPRECATED in v2.x +M.close = manager.close + +--DEPRECATED in v2.x, use manager.close_all() +M.close_all = function(at_position) + renderer.close_all_floating_windows() + if type(at_position) == "string" and at_position > "" then + for _, name in ipairs(sources) do + local pos = get_position(name) + if pos == at_position then + manager.close(name) + end + end + else + for _, name in ipairs(sources) do + manager.close(name) + end + end +end + +--DEPRECATED in v2.x, use commands.execute() +M.float = function(source_name, toggle_if_open) + M.ensure_config() + source_name = check_source(source_name) + if toggle_if_open then + if renderer.close_floating_window(source_name) then + -- It was open, and now it's not. + return + end + end + renderer.close_all_floating_windows() + manager.close(source_name) -- in case this source is open in a sidebar + manager.float(source_name) +end + +--DEPRECATED in v2.x, use commands.execute() +M.focus = function(source_name, close_others, toggle_if_open) + M.ensure_config() + source_name = check_source(source_name) + if get_position(source_name) == "current" then + M.show_in_split(source_name, toggle_if_open) + return + end + + if toggle_if_open then + if manager.close(source_name) then + -- It was open, and now it's not. + return + end + end + if close_others == nil then + close_others = true + end + if close_others then + M.close_all_except(source_name) + end + manager.focus(source_name) +end + +--DEPRECATED in v2.x, use commands.execute() +M.reveal_current_file = function(source_name, toggle_if_open, force_cwd) + M.ensure_config() + source_name = check_source(source_name) + if get_position(source_name) == "current" then + M.reveal_in_split(source_name, toggle_if_open) + return + end + if toggle_if_open then + if manager.close(source_name) then + -- It was open, and now it's not. + return + end + end + manager.reveal_current_file(source_name, nil, force_cwd) +end + +--DEPRECATED in v2.x, use commands.execute() +M.reveal_in_split = function(source_name, toggle_if_open) + M.ensure_config() + source_name = check_source(source_name) + if toggle_if_open then + local state = manager.get_state(source_name, nil, vim.api.nvim_get_current_win()) + if renderer.close(state) then + -- It was open, and now it's not. + return + end + end + --TODO: if we are currently in a sidebar, don't replace it with a split style + manager.reveal_in_split(source_name) +end + +--DEPRECATED in v2.x, use commands.execute() +M.show_in_split = function(source_name, toggle_if_open) + M.ensure_config() + source_name = check_source(source_name) + if toggle_if_open then + local state = manager.get_state(source_name, nil, vim.api.nvim_get_current_win()) + if renderer.close(state) then + -- It was open, and now it's not. + return + end + end + --TODO: if we are currently in a sidebar, don't replace it with a split style + manager.show_in_split(source_name) +end + +M.get_prior_window = function(ignore_filetypes) + ignore_filetypes = ignore_filetypes or {} + local ignore = utils.list_to_dict(ignore_filetypes) + ignore["neo-tree"] = true + + local tabid = vim.api.nvim_get_current_tabpage() + local wins = utils.get_value(M, "config.prior_windows", {}, true)[tabid] + if wins == nil then + return -1 + end + local win_index = #wins + while win_index > 0 do + local last_win = wins[win_index] + if type(last_win) == "number" then + local success, is_valid = pcall(vim.api.nvim_win_is_valid, last_win) + if success and is_valid then + local buf = vim.api.nvim_win_get_buf(last_win) + local ft = vim.api.nvim_buf_get_option(buf, "filetype") + local bt = vim.api.nvim_buf_get_option(buf, "buftype") or "normal" + if ignore[ft] ~= true and ignore[bt] ~= true then + return last_win + end + end + end + win_index = win_index - 1 + end + return -1 +end + +M.paste_default_config = function() + local base_path = debug.getinfo(utils.truthy).source:match("@(.*)/utils.lua$") + local config_path = base_path .. utils.path_separator .. "defaults.lua" + local lines = vim.fn.readfile(config_path) + if lines == nil then + error("Could not read neo-tree.defaults") + end + + -- read up to the end of the config, jut to omit the final return + local config = {} + for _, line in ipairs(lines) do + table.insert(config, line) + if line == "}" then + break + end + end + + vim.api.nvim_put(config, "l", true, false) + vim.schedule(function() + vim.cmd("normal! `[v`]=") + end) +end + +M.buffer_enter_event = setup.buffer_enter_event +M.win_enter_event = setup.win_enter_event + +--DEPRECATED in v2.x +--BREAKING CHANGE: Removed the do_not_focus and close_others options in 2.0 +--M.show = function(source_name, do_not_focus, close_others, toggle_if_open) +M.show = function(source_name, toggle_if_open) + M.ensure_config() + source_name = check_source(source_name) + if get_position(source_name) == "current" then + M.show_in_split(source_name, toggle_if_open) + return + end + + if toggle_if_open then + if manager.close(source_name) then + -- It was open, and now it's not. + return + end + end + + M.close_all_except(source_name) + manager.show(source_name) +end + +M.set_log_level = function(level) + log.set_level(level) +end + +M.setup = function(config, is_auto_config) + M.config = require("neo-tree.setup").merge_config(config, is_auto_config) + local netrw = require("neo-tree.setup.netrw") + if not is_auto_config and netrw.get_hijack_netrw_behavior() ~= "disabled" then + vim.cmd("silent! autocmd! FileExplorer *") + netrw.hijack() + end +end + +M.show_logs = function() + vim.cmd("tabnew " .. log.outfile) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/collections.lua b/bundle/neo-tree.nvim/lua/neo-tree/collections.lua new file mode 100644 index 000000000..a0d76d9d5 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/collections.lua @@ -0,0 +1,127 @@ +local log = require("neo-tree.log") + +Node = {} +function Node:new(value) + local props = { prev = nil, next = nil, value = value } + setmetatable(props, self) + self.__index = self + return props +end + +LinkedList = {} +function LinkedList:new() + local props = { head = nil, tail = nil, size = 0 } + setmetatable(props, self) + self.__index = self + return props +end + +function LinkedList:add_node(node) + if self.head == nil then + self.head = node + self.tail = node + else + self.tail.next = node + node.prev = self.tail + self.tail = node + end + self.size = self.size + 1 + return node +end + +function LinkedList:remove_node(node) + if node.prev ~= nil then + node.prev.next = node.next + end + if node.next ~= nil then + node.next.prev = node.prev + end + if self.head == node then + self.head = node.next + end + if self.tail == node then + self.tail = node.prev + end + self.size = self.size - 1 + node.prev = nil + node.next = nil + node.value = nil +end + +-- First in Last Out +Queue = {} +function Queue:new() + local props = { _list = LinkedList:new() } + setmetatable(props, self) + self.__index = self + return props +end + +---Add an element to the end of the queue. +---@param value any The value to add. +function Queue:add(value) + self._list:add_node(Node:new(value)) +end + +---Iterates over the entire list, running func(value) on each element. +---If func returns true, the element is removed from the list. +---@param func function The function to run on each element. +function Queue:for_each(func) + local node = self._list.head + while node ~= nil do + local result = func(node.value) + local node_is_next = false + if result then + if type(result) == "boolean" then + local node_to_remove = node + node = node.next + node_is_next = true + self._list:remove_node(node_to_remove) + elseif type(result) == "table" then + if type(result.handled) == "boolean" and result.handled == true then + log.trace( + "Handler ", + node.value.id, + " for " + .. node.value.event + .. " returned handled = true, skipping the rest of the queue." + ) + return result + end + end + end + if not node_is_next then + node = node.next + end + end +end + +function Queue:is_empty() + return self._list.size == 0 +end + +function Queue:remove_by_id(id) + local current = self._list.head + while current ~= nil do + local is_match = false + local item = current.value + if item ~= nil then + local item_id = item.id or item + if item_id == id then + is_match = true + end + end + if is_match then + local next = current.next + self._list:remove_node(current) + current = next + else + current = current.next + end + end +end + +return { + Queue = Queue, + LinkedList = LinkedList, +} diff --git a/bundle/neo-tree.nvim/lua/neo-tree/command/completion.lua b/bundle/neo-tree.nvim/lua/neo-tree/command/completion.lua new file mode 100644 index 000000000..5405719d0 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/command/completion.lua @@ -0,0 +1,122 @@ +local parser = require("neo-tree.command.parser") +local utils = require("neo-tree.utils") + +local M = { + show_key_value_completions = true, +} + +local get_path_completions = function(key_prefix, base_path) + key_prefix = key_prefix or "" + local completions = {} + local expanded = parser.resolve_path(base_path) + local path_completions = vim.fn.glob(expanded .. "*", false, true) + for _, completion in ipairs(path_completions) do + if expanded ~= base_path then + -- we need to recreate the relative path from the aboluste path + -- first strip trailing slashes to normalize + if expanded:sub(-1) == utils.path_separator then + expanded = expanded:sub(1, -2) + end + if base_path:sub(-1) == utils.path_separator then + base_path = base_path:sub(1, -2) + end + -- now put just the current completion onto the base_path being used + completion = base_path .. string.sub(completion, #expanded + 1) + end + table.insert(completions, key_prefix .. completion) + end + + return table.concat(completions, "\n") +end + +local get_ref_completions = function(key_prefix) + key_prefix = key_prefix or "" + local completions = { key_prefix .. "HEAD" } + local ok, refs = utils.execute_command("git show-ref") + if not ok then + return "" + end + for _, ref in ipairs(refs) do + local _, i = ref:find("refs%/%a+%/") + if i then + table.insert(completions, key_prefix .. ref:sub(i + 1)) + end + end + + return table.concat(completions, "\n") +end + +M.complete_args = function(argLead, cmdLine) + local candidates = {} + local existing = utils.split(cmdLine, " ") + local parsed = parser.parse(existing, false) + + local eq = string.find(argLead, "=") + if eq == nil then + if M.show_key_value_completions then + -- may be the start of a new key=value pair + for _, key in ipairs(parser.list_args) do + key = tostring(key) + if key:find(argLead, 1, true) and not parsed[key] then + table.insert(candidates, key .. "=") + end + end + + for _, key in ipairs(parser.path_args) do + key = tostring(key) + if key:find(argLead, 1, true) and not parsed[key] then + table.insert(candidates, key .. "=./") + end + end + + for _, key in ipairs(parser.ref_args) do + key = tostring(key) + if key:find(argLead, 1, true) and not parsed[key] then + table.insert(candidates, key .. "=") + end + end + end + else + -- continuation of a key=value pair + local key = string.sub(argLead, 1, eq - 1) + local value = string.sub(argLead, eq + 1) + local arg_type = parser.arg_type_lookup[key] + if arg_type == parser.PATH then + return get_path_completions(key .. "=", value) + elseif arg_type == parser.REF then + return get_ref_completions(key .. "=") + elseif arg_type == parser.LIST then + local valid_values = parser.arguments[key].values + if valid_values and not parsed[key] then + for _, vv in ipairs(valid_values) do + if vv:find(value) then + table.insert(candidates, key .. "=" .. vv) + end + end + end + end + end + + -- may be a value without a key + for value, key in pairs(parser.reverse_lookup) do + value = tostring(value) + local key_already_used = false + if parser.arg_type_lookup[key] == parser.LIST then + key_already_used = type(parsed[key]) ~= "nil" + else + key_already_used = type(parsed[value]) ~= "nil" + end + + if not key_already_used and value:find(argLead, 1, true) then + table.insert(candidates, value) + end + end + + if #candidates == 0 then + -- default to path completion + return get_path_completions(nil, argLead) .. "\n" .. get_ref_completions(nil) + end + return table.concat(candidates, "\n") +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/command/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/command/init.lua new file mode 100644 index 000000000..4d6747c8d --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/command/init.lua @@ -0,0 +1,207 @@ +local parser = require("neo-tree.command.parser") +local log = require("neo-tree.log") +local manager = require("neo-tree.sources.manager") +local utils = require("neo-tree.utils") +local renderer = require("neo-tree.ui.renderer") +local inputs = require("neo-tree.ui.inputs") +local completion = require("neo-tree.command.completion") +local do_show_or_focus, handle_reveal + +local M = { + complete_args = completion.complete_args, +} + +---Executes a Neo-tree action from outside of a Neo-tree window, +---such as show, hide, navigate, etc. +---@param args table The action to execute. The table can have the following keys: +--- action = string The action to execute, can be one of: +--- "close", +--- "focus", <-- default value +--- "show", +--- source = string The source to use for this action. This will default +--- to the default_source specified in the user's config. +--- Can be one of: +--- "filesystem", +--- "buffers", +--- "git_status", +-- "migrations" +--- position = string The position this action will affect. This will default +--- to the the last used position or the position specified +--- in the user's config for the given source. Can be one of: +--- "left", +--- "right", +--- "float", +--- "current" +--- toggle = boolean Whether to toggle the visibility of the Neo-tree window. +--- reveal = boolean Whether to reveal the current file in the Neo-tree window. +--- reveal_file = string The specific file to reveal. +--- dir = string The root directory to set. +--- git_base = string The git base used for diff +M.execute = function(args) + local nt = require("neo-tree") + nt.ensure_config() + + if args.source == "migrations" then + require("neo-tree.setup.deprecations").show_migrations() + return + end + + args.action = args.action or "focus" + + -- handle close action, which can specify a source and/or position + if args.action == "close" then + if args.source then + manager.close(args.source, args.position) + else + manager.close_all(args.position) + end + return + end + + -- The rest of the actions require a source + args.source = args.source or nt.config.default_source + + -- If position=current was requested, but we are currently in a neo-tree window, + -- then we need to override that. + if args.position == "current" and vim.bo.filetype == "neo-tree" then + local position = vim.api.nvim_buf_get_var(0, "neo_tree_position") + if position then + args.position = position + end + end + + -- Now get the correct state + local state + local requested_position = args.position or nt.config[args.source].window.position + if requested_position == "current" then + local winid = vim.api.nvim_get_current_win() + state = manager.get_state(args.source, nil, winid) + else + state = manager.get_state(args.source, nil, nil) + end + + -- Next handle toggle, the rest is irrelevant if there is a window to toggle + if args.toggle then + if renderer.close(state) then + -- It was open, and now it's not. + return + end + end + + -- Handle position override + local default_position = nt.config[args.source].window.position + local current_position = state.current_position or default_position + local position_changed = false + if args.position then + state.current_position = args.position + position_changed = args.position ~= current_position + end + + -- Handle setting directory if requested + local path_changed = false + if utils.truthy(args.dir) then + if #args.dir > 1 and args.dir:sub(-1) == utils.path_separator then + args.dir = args.dir:sub(1, -2) + end + path_changed = state.path ~= args.dir + else + args.dir = state.path + end + + -- Handle setting git ref + local git_base_changed = state.git_base ~= args.git_base + if utils.truthy(args.git_base) then + state.git_base = args.git_base + end + + -- Handle reveal logic + args.reveal = args.reveal or args.reveal_force_cwd + local do_reveal = utils.truthy(args.reveal_file) + if args.reveal and not do_reveal then + args.reveal_file = manager.get_path_to_reveal() + do_reveal = utils.truthy(args.reveal_file) + end + + -- All set, now show or focus the window + local force_navigate = path_changed or do_reveal or git_base_changed or state.dirty + if position_changed and args.position ~= "current" and current_position ~= "current" then + manager.close(args.source) + end + if do_reveal then + handle_reveal(args, state) + else + do_show_or_focus(args, state, force_navigate) + end +end + +---Parses and executes the command line. Use execute(args) instead. +---@param ... string Argument as strings. +M._command = function(...) + local args = parser.parse({ ... }, true) + M.execute(args) +end + +do_show_or_focus = function(args, state, force_navigate) + local window_exists = renderer.window_exists(state) + local function close_other_sources() + if not window_exists then + -- Clear the space in case another source is already open + local target_position = args.position or state.current_position or state.window.position + if target_position ~= "current" then + manager.close_all(target_position) + end + end + end + + if args.action == "show" then + -- "show" means show the window without focusing it + if window_exists and not force_navigate then + -- There's nothing to do here, we are already at the target state + return + end + close_other_sources() + local current_win = vim.api.nvim_get_current_win() + manager.navigate(state, args.dir, args.reveal_file, function() + -- navigate changes the window to neo-tree, so just quickly hop back to the original window + vim.api.nvim_set_current_win(current_win) + end, false) + elseif args.action == "focus" then + -- "focus" mean open and jump to the window if closed, and just focus it if already opened + if window_exists then + vim.api.nvim_set_current_win(state.winid) + end + if force_navigate or not window_exists then + close_other_sources() + manager.navigate(state, args.dir, args.reveal_file, nil, false) + end + end +end + +handle_reveal = function(args, state) + -- Deal with cwd if we need to + local cwd = state.path + local path = args.reveal_file + if cwd == nil then + cwd = manager.get_cwd(state) + end + if args.reveal_force_cwd and not utils.is_subpath(cwd, path) then + args.dir, _ = utils.split_path(path) + do_show_or_focus(args, state, true) + return + elseif not utils.is_subpath(cwd, path) then + -- force was not specified, so we need to ask the user + cwd, _ = utils.split_path(path) + inputs.confirm("File not in cwd. Change cwd to " .. cwd .. "?", function(response) + if response == true then + args.dir = cwd + else + args.reveal_file = nil + end + do_show_or_focus(args, state, true) + end) + return + else + do_show_or_focus(args, state, true) + end +end +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/command/parser.lua b/bundle/neo-tree.nvim/lua/neo-tree/command/parser.lua new file mode 100644 index 000000000..f9c5b4896 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/command/parser.lua @@ -0,0 +1,175 @@ +local utils = require("neo-tree.utils") + +local M = { + FLAG = "", + LIST = "", + PATH = "", + REF = "", +} + +M.setup = function(all_source_names) + local source_names = utils.table_copy(all_source_names) + table.insert(source_names, "migrations") + + -- For lists, the first value is the default value. + local arguments = { + action = { + type = M.LIST, + values = { + "close", + "focus", + "show", + }, + }, + position = { + type = M.LIST, + values = { + "left", + "right", + "top", + "bottom", + "float", + "current", + }, + }, + source = { + type = M.LIST, + values = source_names, + }, + dir = { type = M.PATH, stat_type = "directory" }, + reveal_file = { type = M.PATH, stat_type = "file" }, + git_base = { type = M.REF }, + toggle = { type = M.FLAG }, + reveal = { type = M.FLAG }, + reveal_force_cwd = { type = M.FLAG }, + } + + local arg_type_lookup = {} + local list_args = {} + local path_args = {} + local ref_args = {} + local flag_args = {} + local reverse_lookup = {} + for name, def in pairs(arguments) do + arg_type_lookup[name] = def.type + if def.type == M.LIST then + table.insert(list_args, name) + for _, vv in ipairs(def.values) do + reverse_lookup[tostring(vv)] = name + end + elseif def.type == M.PATH then + table.insert(path_args, name) + elseif def.type == M.FLAG then + table.insert(flag_args, name) + reverse_lookup[name] = M.FLAG + elseif def.type == M.REF then + table.insert(ref_args, name) + else + error("Unknown type: " .. def.type) + end + end + + M.arguments = arguments + M.list_args = list_args + M.path_args = path_args + M.ref_args = ref_args + M.flag_args = flag_args + M.arg_type_lookup = arg_type_lookup + M.reverse_lookup = reverse_lookup +end + +M.resolve_path = function(path, validate_type) + local expanded = vim.fn.expand(path) + local abs_path = vim.fn.fnamemodify(expanded, ":p") + if validate_type then + local stat = vim.loop.fs_stat(abs_path) + if stat.type ~= validate_type then + error("Invalid path: " .. path .. " is not a " .. validate_type) + end + end + return abs_path +end + +M.verify_git_ref = function(ref) + local ok, _ = utils.execute_command("git rev-parse --verify " .. ref) + return ok +end + +local parse_arg = function(result, arg) + if type(arg) == "string" then + local eq = arg:find("=") + if eq then + local key = arg:sub(1, eq - 1) + local value = arg:sub(eq + 1) + local def = M.arguments[key] + if not def.type then + error("Invalid argument: " .. arg) + end + + if def.type == M.PATH then + result[key] = M.resolve_path(value, def.stat_type) + elseif def.type == M.FLAG then + if value == "true" then + result[key] = true + elseif value == "false" then + result[key] = false + else + error("Invalid value for " .. key .. ": " .. value) + end + elseif def.type == M.REF then + if not M.verify_git_ref(value) then + error("Invalid value for " .. key .. ": " .. value) + end + result[key] = value + else + result[key] = value + end + else + local value = arg + local key = M.reverse_lookup[value] + if key == nil then + -- maybe it's a git ref + if M.verify_git_ref(value) then + result["git_base"] = value + return + end + -- maybe it's a path + local path = M.resolve_path(value) + local stat = vim.loop.fs_stat(path) + if stat then + if stat.type == "directory" then + result["dir"] = path + elseif stat.type == "file" then + result["reveal_file"] = path + end + else + error("Invalid argument: " .. arg) + end + elseif key == M.FLAG then + result[value] = true + else + result[key] = value + end + end + end +end + +M.parse = function(args, strict_checking) + require("neo-tree").ensure_config() + local result = {} + + if type(args) == "string" then + args = utils.split(args, " ") + end + -- read args from user + for _, arg in ipairs(args) do + local success, err = pcall(parse_arg, result, arg) + if strict_checking and not success then + error(err) + end + end + + return result +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/defaults.lua b/bundle/neo-tree.nvim/lua/neo-tree/defaults.lua new file mode 100644 index 000000000..e42d63820 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/defaults.lua @@ -0,0 +1,615 @@ +local config = { + -- If a user has a sources list it will replace this one. + -- Only sources listed here will be loaded. + -- You can also add an external source by adding it's name to this list. + -- The name used here must be the same name you would use in a require() call. + sources = { + "filesystem", + "buffers", + "git_status", + -- "document_symbols", + }, + add_blank_line_at_top = false, -- Add a blank line at the top of the tree. + auto_clean_after_session_restore = false, -- Automatically clean up broken neo-tree buffers saved in sessions + close_if_last_window = false, -- Close Neo-tree if it is the last window left in the tab + -- popup_border_style is for input and confirmation dialogs. + -- Configurtaion of floating window is done in the individual source sections. + -- "NC" is a special style that works well with NormalNC set + close_floats_on_escape_key = true, + default_source = "filesystem", + enable_diagnostics = true, + enable_git_status = true, + enable_modified_markers = true, -- Show markers for files with unsaved changes. + enable_opened_markers = true, -- Enable tracking of opened files. Required for `components.name.highlight_opened_files` + enable_refresh_on_write = true, -- Refresh the tree when a file is written. Only used if `use_libuv_file_watcher` is false. + git_status_async = true, + -- These options are for people with VERY large git repos + git_status_async_options = { + batch_size = 1000, -- how many lines of git status results to process at a time + batch_delay = 10, -- delay in ms between batches. Spreads out the workload to let other processes run. + max_lines = 10000, -- How many lines of git status results to process. Anything after this will be dropped. + -- Anything before this will be used. The last items to be processed are the untracked files. + }, + hide_root_node = false, -- Hide the root node. + retain_hidden_root_indent = false, -- IF the root node is hidden, keep the indentation anyhow. + -- This is needed if you use expanders because they render in the indent. + log_level = "info", -- "trace", "debug", "info", "warn", "error", "fatal" + log_to_file = false, -- true, false, "/path/to/file.log", use :NeoTreeLogs to show the file + open_files_in_last_window = true, -- false = open files in top left window + open_files_do_not_replace_types = { "terminal", "trouble", "qf" }, -- when opening files, do not use windows containing these filetypes or buftypes + popup_border_style = "NC", -- "double", "none", "rounded", "shadow", "single" or "solid" + resize_timer_interval = 500, -- in ms, needed for containers to redraw right aligned and faded content + -- set to -1 to disable the resize timer entirely + -- -- NOTE: this will speed up to 50 ms for 1 second following a resize + sort_case_insensitive = false, -- used when sorting files and directories in the tree + sort_function = nil , -- uses a custom function for sorting files and directories in the tree + use_popups_for_input = true, -- If false, inputs will use vim.ui.input() instead of custom floats. + use_default_mappings = true, + -- source_selector provides clickable tabs to switch between sources. + source_selector = { + winbar = false, -- toggle to show selector on winbar + statusline = false, -- toggle to show selector on statusline + show_scrolled_off_parent_node = false, -- this will replace the tabs with the parent path + -- of the top visible node when scrolled down. + sources = { + { source = "filesystem" }, + { source = "buffers" }, + { source = "git_status" }, + }, + content_layout = "start", -- only with `tabs_layout` = "equal", "focus" + -- start : |/ 裡 bufname \/... + -- end : |/ 裡 bufname \/... + -- center : |/ 裡 bufname \/... + tabs_layout = "equal", -- start, end, center, equal, focus + -- start : |/ a \/ b \/ c \ | + -- end : | / a \/ b \/ c \| + -- center : | / a \/ b \/ c \ | + -- equal : |/ a \/ b \/ c \| + -- active : |/ focused tab \/ b \/ c \| + truncation_character = "…", -- character to use when truncating the tab label + tabs_min_width = nil, -- nil | int: if int padding is added based on `content_layout` + tabs_max_width = nil, -- this will truncate text even if `text_trunc_to_fit = false` + padding = 0, -- can be int or table + -- padding = { left = 2, right = 0 }, + -- separator = "▕", -- can be string or table, see below + separator = { left = "▏", right= "▕" }, + -- separator = { left = "/", right = "\\", override = nil }, -- |/ a \/ b \/ c \... + -- separator = { left = "/", right = "\\", override = "right" }, -- |/ a \ b \ c \... + -- separator = { left = "/", right = "\\", override = "left" }, -- |/ a / b / c /... + -- separator = { left = "/", right = "\\", override = "active" },-- |/ a / b:active \ c \... + -- separator = "|", -- || a | b | c |... + separator_active = nil, -- set separators around the active tab. nil falls back to `source_selector.separator` + show_separator_on_edge = false, + -- true : |/ a \/ b \/ c \| + -- false : | a \/ b \/ c | + highlight_tab = "NeoTreeTabInactive", + highlight_tab_active = "NeoTreeTabActive", + highlight_background = "NeoTreeTabInactive", + highlight_separator = "NeoTreeTabSeparatorInactive", + highlight_separator_active = "NeoTreeTabSeparatorActive", + }, + -- + --event_handlers = { + -- { + -- event = "before_render", + -- handler = function (state) + -- -- add something to the state that can be used by custom components + -- end + -- }, + -- { + -- event = "file_opened", + -- handler = function(file_path) + -- --auto close + -- require("neo-tree").close_all() + -- end + -- }, + -- { + -- event = "file_opened", + -- handler = function(file_path) + -- --clear search after opening a file + -- require("neo-tree.sources.filesystem").reset_search() + -- end + -- }, + -- { + -- event = "file_renamed", + -- handler = function(args) + -- -- fix references to file + -- print(args.source, " renamed to ", args.destination) + -- end + -- }, + -- { + -- event = "file_moved", + -- handler = function(args) + -- -- fix references to file + -- print(args.source, " moved to ", args.destination) + -- end + -- }, + -- { + -- event = "neo_tree_buffer_enter", + -- handler = function() + -- vim.cmd 'highlight! Cursor blend=100' + -- end + -- }, + -- { + -- event = "neo_tree_buffer_leave", + -- handler = function() + -- vim.cmd 'highlight! Cursor guibg=#5f87af blend=0' + -- end + -- }, + -- { + -- event = "neo_tree_window_before_open", + -- handler = function(args) + -- print("neo_tree_window_before_open", vim.inspect(args)) + -- end + -- }, + -- { + -- event = "neo_tree_window_after_open", + -- handler = function(args) + -- vim.cmd("wincmd =") + -- end + -- }, + -- { + -- event = "neo_tree_window_before_close", + -- handler = function(args) + -- print("neo_tree_window_before_close", vim.inspect(args)) + -- end + -- }, + -- { + -- event = "neo_tree_window_after_close", + -- handler = function(args) + -- vim.cmd("wincmd =") + -- end + -- } + --}, + default_component_configs = { + container = { + enable_character_fade = true, + width = "100%", + right_padding = 0, + }, + --diagnostics = { + -- symbols = { + -- hint = "H", + -- info = "I", + -- warn = "!", + -- error = "X", + -- }, + -- highlights = { + -- hint = "DiagnosticSignHint", + -- info = "DiagnosticSignInfo", + -- warn = "DiagnosticSignWarn", + -- error = "DiagnosticSignError", + -- }, + --}, + indent = { + indent_size = 2, + padding = 1, + -- indent guides + with_markers = true, + indent_marker = "│", + last_indent_marker = "└", + highlight = "NeoTreeIndentMarker", + -- expander config, needed for nesting files + with_expanders = nil, -- if nil and file nesting is enabled, will enable expanders + expander_collapsed = "", + expander_expanded = "", + expander_highlight = "NeoTreeExpander", + }, + icon = { + folder_closed = "", + folder_open = "", + folder_empty = "ﰊ", + folder_empty_open = "ﰊ", + -- The next two settings are only a fallback, if you use nvim-web-devicons and configure default icons there + -- then these will never be used. + default = "*", + highlight = "NeoTreeFileIcon" + }, + modified = { + symbol = "[+] ", + highlight = "NeoTreeModified", + }, + name = { + trailing_slash = false, + highlight_opened_files = false, -- Requires `enable_opened_markers = true`. + -- Take values in { false (no highlight), true (only loaded), + -- "all" (both loaded and unloaded)}. For more information, + -- see the `show_unloaded` config of the `buffers` source. + use_git_status_colors = true, + highlight = "NeoTreeFileName", + }, + git_status = { + symbols = { + -- Change type + added = "✚", -- NOTE: you can set any of these to an empty string to not show them + deleted = "✖", + modified = "", + renamed = "", + -- Status type + untracked = "", + ignored = "", + unstaged = "", + staged = "", + conflict = "", + }, + align = "right", + }, + }, + renderers = { + directory = { + { "indent" }, + { "icon" }, + { "current_filter" }, + { + "container", + content = { + { "name", zindex = 10 }, + -- { + -- "symlink_target", + -- zindex = 10, + -- highlight = "NeoTreeSymbolicLinkTarget", + -- }, + { "clipboard", zindex = 10 }, + { "diagnostics", errors_only = true, zindex = 20, align = "right", hide_when_expanded = true }, + { "git_status", zindex = 20, align = "right", hide_when_expanded = true }, + }, + }, + }, + file = { + { "indent" }, + { "icon" }, + { + "container", + content = { + { + "name", + zindex = 10 + }, + -- { + -- "symlink_target", + -- zindex = 10, + -- highlight = "NeoTreeSymbolicLinkTarget", + -- }, + { "clipboard", zindex = 10 }, + { "bufnr", zindex = 10 }, + { "modified", zindex = 20, align = "right" }, + { "diagnostics", zindex = 20, align = "right" }, + { "git_status", zindex = 20, align = "right" }, + }, + }, + }, + message = { + { "indent", with_markers = false }, + { "name", highlight = "NeoTreeMessage" }, + }, + terminal = { + { "indent" }, + { "icon" }, + { "name" }, + { "bufnr" } + } + }, + nesting_rules = {}, + -- Global custom commands that will be available in all sources (if not overridden in `opts[source_name].commands`) + -- + -- You can then reference the custom command by adding a mapping to it: + -- globally -> `opts.window.mappings` + -- locally -> `opt[source_name].window.mappings` to make it source specific. + -- + -- commands = { | window { | filesystem { + -- hello = function() | mappings = { | commands = { + -- print("Hello world") | [""] = "hello" | hello = function() + -- end | } | print("Hello world in filesystem") + -- } | } | end + -- + -- see `:h neo-tree-global-custom-commands` + commands = {}, -- A list of functions + + window = { -- see https://github.com/MunifTanjim/nui.nvim/tree/main/lua/nui/popup for + -- possible options. These can also be functions that return these options. + position = "left", -- left, right, top, bottom, float, current + width = 40, -- applies to left and right positions + height = 15, -- applies to top and bottom positions + auto_expand_width = false, -- expand the window when file exceeds the window width. does not work with position = "float" + popup = { -- settings that apply to float position only + size = { + height = "80%", + width = "50%", + }, + position = "50%", -- 50% means center it + -- you can also specify border here, if you want a different setting from + -- the global popup_border_style. + }, + same_level = false, -- Create and paste/move files/directories on the same level as the directory under cursor (as opposed to within the directory under cursor). + insert_as = "child", -- Affects how nodes get inserted into the tree during creation/pasting/moving of files if the node under the cursor is a directory: + -- "child": Insert nodes as children of the directory under cursor. + -- "sibling": Insert nodes as siblings of the directory under cursor. + -- Mappings for tree window. See `:h neo-tree-mappings` for a list of built-in commands. + -- You can also create your own commands by providing a function instead of a string. + mapping_options = { + noremap = true, + nowait = true, + }, + mappings = { + [""] = { + "toggle_node", + nowait = false, -- disable `nowait` if you have existing combos starting with this char that you want to use + }, + ["<2-LeftMouse>"] = "open", + [""] = "open", + [""] = "revert_preview", + ["P"] = { "toggle_preview", config = { use_float = true } }, + ["l"] = "focus_preview", + ["S"] = "open_split", + -- ["S"] = "split_with_window_picker", + ["s"] = "open_vsplit", + -- ["s"] = "vsplit_with_window_picker", + ["t"] = "open_tabnew", + -- [""] = "open_drop", + -- ["t"] = "open_tab_drop", + ["w"] = "open_with_window_picker", + ["C"] = "close_node", + ["z"] = "close_all_nodes", + --["Z"] = "expand_all_nodes", + ["R"] = "refresh", + ["a"] = { + "add", + -- some commands may take optional config options, see `:h neo-tree-mappings` for details + config = { + show_path = "none", -- "none", "relative", "absolute" + } + }, + ["A"] = "add_directory", -- also accepts the config.show_path and config.insert_as options. + ["d"] = "delete", + ["r"] = "rename", + ["y"] = "copy_to_clipboard", + ["x"] = "cut_to_clipboard", + ["p"] = "paste_from_clipboard", + ["c"] = "copy", -- takes text input for destination, also accepts the config.show_path and config.insert_as options + ["m"] = "move", -- takes text input for destination, also accepts the config.show_path and config.insert_as options + ["e"] = "toggle_auto_expand_width", + ["q"] = "close_window", + ["?"] = "show_help", + ["<"] = "prev_source", + [">"] = "next_source", + }, + }, + filesystem = { + window = { + mappings = { + ["H"] = "toggle_hidden", + ["/"] = "fuzzy_finder", + ["D"] = "fuzzy_finder_directory", + --["/"] = "filter_as_you_type", -- this was the default until v1.28 + ["#"] = "fuzzy_sorter", -- fuzzy sorting using the fzy algorithm + -- ["D"] = "fuzzy_sorter_directory", + ["f"] = "filter_on_submit", + [""] = "clear_filter", + [""] = "navigate_up", + ["."] = "set_root", + ["[g"] = "prev_git_modified", + ["]g"] = "next_git_modified", + }, + fuzzy_finder_mappings = { -- define keymaps for filter popup window in fuzzy_finder_mode + [""] = "move_cursor_down", + [""] = "move_cursor_down", + [""] = "move_cursor_up", + [""] = "move_cursor_up", + }, + }, + async_directory_scan = "auto", -- "auto" means refreshes are async, but it's synchronous when called from the Neotree commands. + -- "always" means directory scans are always async. + -- "never" means directory scans are never async. + scan_mode = "shallow", -- "shallow": Don't scan into directories to detect possible empty directory a priori + -- "deep": Scan into directories to detect empty or grouped empty directories a priori. + bind_to_cwd = true, -- true creates a 2-way binding between vim's cwd and neo-tree's root + cwd_target = { + sidebar = "tab", -- sidebar is when position = left or right + current = "window" -- current is when position = current + }, + -- The renderer section provides the renderers that will be used to render the tree. + -- The first level is the node type. + -- For each node type, you can specify a list of components to render. + -- Components are rendered in the order they are specified. + -- The first field in each component is the name of the function to call. + -- The rest of the fields are passed to the function as the "config" argument. + filtered_items = { + visible = false, -- when true, they will just be displayed differently than normal items + force_visible_in_empty_folder = false, -- when true, hidden files will be shown if the root folder is otherwise empty + show_hidden_count = true, -- when true, the number of hidden items in each folder will be shown as the last entry + hide_dotfiles = true, + hide_gitignored = true, + hide_hidden = true, -- only works on Windows for hidden files/directories + hide_by_name = { + ".DS_Store", + "thumbs.db" + --"node_modules", + }, + hide_by_pattern = { -- uses glob style patterns + --"*.meta", + --"*/src/*/tsconfig.json" + }, + always_show = { -- remains visible even if other settings would normally hide it + --".gitignored", + }, + never_show = { -- remains hidden even if visible is toggled to true, this overrides always_show + --".DS_Store", + --"thumbs.db" + }, + never_show_by_pattern = { -- uses glob style patterns + --".null-ls_*", + }, + }, + find_by_full_path_words = false, -- `false` means it only searches the tail of a path. + -- `true` will change the filter into a full path + -- search with space as an implicit ".*", so + -- `fi init` + -- will match: `./sources/filesystem/init.lua + --find_command = "fd", -- this is determined automatically, you probably don't need to set it + --find_args = { -- you can specify extra args to pass to the find command. + -- fd = { + -- "--exclude", ".git", + -- "--exclude", "node_modules" + -- } + --}, + ---- or use a function instead of list of strings + --find_args = function(cmd, path, search_term, args) + -- if cmd ~= "fd" then + -- return args + -- end + -- --maybe you want to force the filter to always include hidden files: + -- table.insert(args, "--hidden") + -- -- but no one ever wants to see .git files + -- table.insert(args, "--exclude") + -- table.insert(args, ".git") + -- -- or node_modules + -- table.insert(args, "--exclude") + -- table.insert(args, "node_modules") + -- --here is where it pays to use the function, you can exclude more for + -- --short search terms, or vary based on the directory + -- if string.len(search_term) < 4 and path == "/home/cseickel" then + -- table.insert(args, "--exclude") + -- table.insert(args, "Library") + -- end + -- return args + --end, + group_empty_dirs = false, -- when true, empty folders will be grouped together + search_limit = 50, -- max number of search results when using filters + follow_current_file = false, -- This will find and focus the file in the active buffer every time + -- the current file is changed while the tree is open. + hijack_netrw_behavior = "open_default", -- netrw disabled, opening a directory opens neo-tree + -- in whatever position is specified in window.position + -- "open_current",-- netrw disabled, opening a directory opens within the + -- window like netrw would, regardless of window.position + -- "disabled", -- netrw left alone, neo-tree does not handle opening dirs + use_libuv_file_watcher = false, -- This will use the OS level file watchers to detect changes + -- instead of relying on nvim autocmd events. + }, + buffers = { + bind_to_cwd = true, + follow_current_file = true, -- This will find and focus the file in the active buffer every time + -- the current file is changed while the tree is open. + group_empty_dirs = true, -- when true, empty directories will be grouped together + show_unloaded = false, -- When working with sessions, for example, restored but unfocused buffers + -- are mark as "unloaded". Turn this on to view these unloaded buffer. + window = { + mappings = { + [""] = "navigate_up", + ["."] = "set_root", + ["bd"] = "buffer_delete", + }, + }, + }, + git_status = { + window = { + mappings = { + ["A"] = "git_add_all", + ["gu"] = "git_unstage_file", + ["ga"] = "git_add_file", + ["gr"] = "git_revert_file", + ["gc"] = "git_commit", + ["gp"] = "git_push", + ["gg"] = "git_commit_and_push", + }, + }, + }, + document_symbols = { + follow_cursor = false, + client_filters = "first", + renderers = { + root = { + {"indent"}, + {"icon", default="C" }, + {"name", zindex = 10}, + }, + symbol = { + {"indent", with_expanders = true}, + {"kind_icon", default="?" }, + {"container", + content = { + {"name", zindex = 10}, + {"kind_name", zindex = 20, align = "right"}, + } + } + }, + }, + window = { + mappings = { + [""] = "jump_to_symbol", + ["o"] = "jump_to_symbol", + ["A"] = "noop", -- also accepts the config.show_path and config.insert_as options. + ["d"] = "noop", + ["y"] = "noop", + ["x"] = "noop", + ["p"] = "noop", + ["c"] = "noop", + ["m"] = "noop", + ["a"] = "noop", + ["/"] = "filter", + ["f"] = "filter_on_submit", + }, + }, + custom_kinds = { + -- define custom kinds here (also remember to add icon and hl group to kinds) + -- ccls + -- [252] = 'TypeAlias', + -- [253] = 'Parameter', + -- [254] = 'StaticMethod', + -- [255] = 'Macro', + }, + kinds = { + Unknown = { icon = "?", hl = "" }, + Root = { icon = "", hl = "NeoTreeRootName" }, + File = { icon = "", hl = "Tag" }, + Module = { icon = "", hl = "Exception" }, + Namespace = { icon = "", hl = "Include" }, + Package = { icon = "", hl = "Label" }, + Class = { icon = "", hl = "Include" }, + Method = { icon = "", hl = "Function" }, + Property = { icon = "", hl = "@property" }, + Field = { icon = "", hl = "@field" }, + Constructor = { icon = "", hl = "@constructor" }, + Enum = { icon = "了", hl = "@number" }, + Interface = { icon = "", hl = "Type" }, + Function = { icon = "", hl = "Function" }, + Variable = { icon = "", hl = "@variable" }, + Constant = { icon = "", hl = "Constant" }, + String = { icon = "", hl = "String" }, + Number = { icon = "", hl = "Number" }, + Boolean = { icon = "", hl = "Boolean" }, + Array = { icon = "", hl = "Type" }, + Object = { icon = "", hl = "Type" }, + Key = { icon = "", hl = "" }, + Null = { icon = "", hl = "Constant" }, + EnumMember = { icon = "", hl = "Number" }, + Struct = { icon = "", hl = "Type" }, + Event = { icon = "", hl = "Constant" }, + Operator = { icon = "", hl = "Operator" }, + TypeParameter = { icon = "", hl = "Type" }, + + -- ccls + -- TypeAlias = { icon = ' ', hl = 'Type' }, + -- Parameter = { icon = ' ', hl = '@parameter' }, + -- StaticMethod = { icon = 'ﴂ ', hl = 'Function' }, + -- Macro = { icon = ' ', hl = 'Macro' }, + } + }, + example = { + renderers = { + custom = { + {"indent"}, + {"icon", default="C" }, + {"custom"}, + {"name"} + } + }, + window = { + mappings = { + [""] = "toggle_node", + [""] = "example_command", + ["d"] = "show_debug_info", + }, + }, + }, +} +return config diff --git a/bundle/neo-tree.nvim/lua/neo-tree/events/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/events/init.lua new file mode 100644 index 000000000..11a911a73 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/events/init.lua @@ -0,0 +1,93 @@ +local vim = vim +local q = require("neo-tree.events.queue") +local log = require("neo-tree.log") +local utils = require("neo-tree.utils") + +local M = { + -- Well known event names, you can make up your own + BEFORE_RENDER = "before_render", + AFTER_RENDER = "after_render", + FILE_ADDED = "file_added", + FILE_DELETED = "file_deleted", + BEFORE_FILE_MOVE = "before_file_move", + FILE_MOVED = "file_moved", + FILE_OPEN_REQUESTED = "file_open_requested", + FILE_OPENED = "file_opened", + BEFORE_FILE_RENAME = "before_file_rename", + FILE_RENAMED = "file_renamed", + FS_EVENT = "fs_event", + GIT_EVENT = "git_event", + GIT_STATUS_CHANGED = "git_status_changed", + NEO_TREE_BUFFER_ENTER = "neo_tree_buffer_enter", + NEO_TREE_BUFFER_LEAVE = "neo_tree_buffer_leave", + NEO_TREE_LSP_UPDATE = "neo_tree_lsp_update", + NEO_TREE_POPUP_BUFFER_ENTER = "neo_tree_popup_buffer_enter", + NEO_TREE_POPUP_BUFFER_LEAVE = "neo_tree_popup_buffer_leave", + NEO_TREE_WINDOW_AFTER_CLOSE = "neo_tree_window_after_close", + NEO_TREE_WINDOW_AFTER_OPEN = "neo_tree_window_after_open", + NEO_TREE_WINDOW_BEFORE_CLOSE = "neo_tree_window_before_close", + NEO_TREE_WINDOW_BEFORE_OPEN = "neo_tree_window_before_open", + VIM_AFTER_SESSION_LOAD = "vim_after_session_load", + VIM_BUFFER_ADDED = "vim_buffer_added", + VIM_BUFFER_CHANGED = "vim_buffer_changed", + VIM_BUFFER_DELETED = "vim_buffer_deleted", + VIM_BUFFER_ENTER = "vim_buffer_enter", + VIM_BUFFER_MODIFIED_SET = "vim_buffer_modified_set", + VIM_COLORSCHEME = "vim_colorscheme", + VIM_CURSOR_MOVED = "vim_cursor_moved", + VIM_DIAGNOSTIC_CHANGED = "vim_diagnostic_changed", + VIM_DIR_CHANGED = "vim_dir_changed", + VIM_INSERT_LEAVE = "vim_insert_leave", + VIM_LEAVE = "vim_leave", + VIM_LSP_REQUEST = "vim_lsp_request", + VIM_RESIZED = "vim_resized", + VIM_TAB_CLOSED = "vim_tab_closed", + VIM_TERMINAL_ENTER = "vim_terminal_enter", + VIM_TEXT_CHANGED_NORMAL = "vim_text_changed_normal", + VIM_WIN_CLOSED = "vim_win_closed", + VIM_WIN_ENTER = "vim_win_enter", +} + +M.define_autocmd_event = function(event_name, autocmds, debounce_frequency, seed_fn, nested) + local opts = { + setup = function() + local tpl = + ":lua require('neo-tree.events').fire_event('%s', { afile = vim.fn.expand('') })" + local callback = string.format(tpl, event_name) + if nested then + callback = "++nested " .. callback + end + + local autocmd = table.concat(autocmds, ",") + if not vim.startswith(autocmd, "User") then + autocmd = autocmd .. " *" + end + local cmds = { + "augroup NeoTreeEvent_" .. event_name, + "autocmd " .. autocmd .. " " .. callback, + "augroup END", + } + log.trace("Registering autocmds: %s", table.concat(cmds, "\n")) + vim.cmd(table.concat(cmds, "\n")) + end, + seed = seed_fn, + teardown = function() + log.trace("Teardown autocmds for ", event_name) + vim.cmd(string.format("autocmd! NeoTreeEvent_%s", event_name)) + end, + debounce_frequency = debounce_frequency, + debounce_strategy = utils.debounce_strategy.CALL_LAST_ONLY, + } + log.debug("Defining autocmd event: %s", event_name) + q.define_event(event_name, opts) +end + +M.clear_all_events = q.clear_all_events +M.define_event = q.define_event +M.destroy_event = q.destroy_event +M.fire_event = q.fire_event + +M.subscribe = q.subscribe +M.unsubscribe = q.unsubscribe + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/events/queue.lua b/bundle/neo-tree.nvim/lua/neo-tree/events/queue.lua new file mode 100644 index 000000000..6797213b5 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/events/queue.lua @@ -0,0 +1,144 @@ +local utils = require("neo-tree.utils") +local log = require("neo-tree.log") +local Queue = require("neo-tree.collections").Queue + +local event_queues = {} +local event_definitions = {} +local M = {} + +local validate_event_handler = function(event_handler) + if type(event_handler) ~= "table" then + error("Event handler must be a table") + end + if type(event_handler.event) ~= "string" then + error("Event handler must have an event") + end + if type(event_handler.handler) ~= "function" then + error("Event handler must have a handler") + end +end + +M.clear_all_events = function() + for event_name, queue in pairs(event_queues) do + M.destroy_event(event_name) + end + event_queues = {} +end + +M.define_event = function(event_name, opts) + local existing = event_definitions[event_name] + if existing ~= nil then + error("Event already defined: " .. event_name) + end + event_definitions[event_name] = opts +end + +M.destroy_event = function(event_name) + local existing = event_definitions[event_name] + if existing == nil then + return false + end + if existing.setup_was_run and type(existing.teardown) == "function" then + local success, result = pcall(existing.teardown) + if not success then + error("Error in teardown for " .. event_name .. ": " .. result) + end + existing.setup_was_run = false + end + event_queues[event_name] = nil + return true +end + +local fire_event_internal = function(event, args) + local queue = event_queues[event] + if queue == nil then + return nil + end + --log.trace("Firing event: ", event, " with args: ", args) + + if queue:is_empty() then + --log.trace("Event queue is empty") + return nil + end + local seed = utils.get_value(event_definitions, event .. ".seed") + if seed ~= nil then + local success, result = pcall(seed, args) + if success and result then + log.trace("Seed for " .. event .. " returned: " .. tostring(result)) + elseif success then + log.trace("Seed for " .. event .. " returned falsy, cancelling event") + else + log.error("Error in seed function for " .. event .. ": " .. result) + end + end + + return queue:for_each(function(event_handler) + local remove_node = event_handler == nil or event_handler.cancelled + if not remove_node then + local success, result = pcall(event_handler.handler, args) + local id = event_handler.id or event_handler + if success then + log.trace("Handler ", id, " for " .. event .. " called successfully.") + else + log.error(string.format("Error in event handler for event %s[%s]: %s", event, id, result)) + end + if event_handler.once then + event_handler.cancelled = true + return true + end + return result + end + end) +end + +M.fire_event = function(event, args) + local freq = utils.get_value(event_definitions, event .. ".debounce_frequency", 0, true) + local strategy = utils.get_value(event_definitions, event .. ".debounce_strategy", 0, true) + log.trace("Firing event: ", event, " with args: ", args) + if freq > 0 then + utils.debounce("EVENT_FIRED: " .. event, function() + fire_event_internal(event, args or {}) + end, freq, strategy) + else + return fire_event_internal(event, args or {}) + end +end + +M.subscribe = function(event_handler) + validate_event_handler(event_handler) + + local queue = event_queues[event_handler.event] + if queue == nil then + log.debug("Creating queue for event: " .. event_handler.event) + queue = Queue:new() + local def = event_definitions[event_handler.event] + if def and type(def.setup) == "function" then + local success, result = pcall(def.setup) + if success then + def.setup_was_run = true + log.debug("Setup for event " .. event_handler.event .. " was run") + else + log.error("Error in setup for " .. event_handler.event .. ": " .. result) + end + end + event_queues[event_handler.event] = queue + end + log.debug("Adding event handler [", event_handler.id, "] for event: ", event_handler.event) + queue:add(event_handler) +end + +M.unsubscribe = function(event_handler) + local queue = event_queues[event_handler.event] + if queue == nil then + return nil + end + queue:remove_by_id(event_handler.id or event_handler) + if queue:is_empty() then + M.destroy_event(event_handler.event) + event_queues[event_handler.event] = nil + else + event_queues[event_handler.event] = queue + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/git/ignored.lua b/bundle/neo-tree.nvim/lua/neo-tree/git/ignored.lua new file mode 100644 index 000000000..2898cf4f5 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/git/ignored.lua @@ -0,0 +1,156 @@ +local Job = require("plenary.job") + +local utils = require("neo-tree.utils") +local log = require("neo-tree.log") +local git_utils = require("neo-tree.git.utils") + +local M = {} +local sep = utils.path_separator + +M.is_ignored = function(ignored, path, _type) + if _type == "directory" and not utils.is_windows then + path = path .. sep + end + + return vim.tbl_contains(ignored, path) +end + +local git_root_cache = { + known_roots = {}, + dir_lookup = {}, +} +local get_root_for_item = function(item) + local dir = item.type == "directory" and item.path or item.parent_path + if type(git_root_cache.dir_lookup[dir]) ~= "nil" then + return git_root_cache.dir_lookup[dir] + end + --for _, root in ipairs(git_root_cache.known_roots) do + -- if vim.startswith(dir, root) then + -- git_root_cache.dir_lookup[dir] = root + -- return root + -- end + --end + local root = git_utils.get_repository_root(dir) + if root then + git_root_cache.dir_lookup[dir] = root + table.insert(git_root_cache.known_roots, root) + else + git_root_cache.dir_lookup[dir] = false + end + return root +end + +M.mark_ignored = function(state, items, callback) + local folders = {} + log.trace("================================================================================") + log.trace("IGNORED: mark_ignore BEGIN...") + + for _, item in ipairs(items) do + local folder = utils.split_path(item.path) + if folder then + if not folders[folder] then + folders[folder] = {} + end + table.insert(folders[folder], item.path) + end + end + + local function process_result(result) + if utils.is_windows then + --on Windows, git seems to return quotes and double backslash "path\\directory" + result = vim.tbl_map(function(item) + item = item:gsub("\\\\", "\\") + return item + end, result) + else + --check-ignore does not indicate directories the same as 'status' so we need to + --add the trailing slash to the path manually if not on Windows. + log.trace("IGNORED: Checking types of", #result, "items to see which ones are directories") + for i, item in ipairs(result) do + local stat = vim.loop.fs_stat(item) + if stat and stat.type == "directory" then + result[i] = item .. sep + end + end + end + result = vim.tbl_map(function(item) + -- remove leading and trailing " from git output + item = item:gsub('^"', ""):gsub('"$', "") + -- convert octal encoded lines to utf-8 + item = git_utils.octal_to_utf8(item) + return item + end, result) + return result + end + + local function finalize(all_results) + local show_anyway = state.filtered_items and state.filtered_items.hide_gitignored == false + log.trace("IGNORED: Comparing results to mark items as ignored, show_anyway:", show_anyway) + local ignored, not_ignored = 0, 0 + for _, item in ipairs(items) do + if M.is_ignored(all_results, item.path, item.type) then + item.filtered_by = item.filtered_by or {} + item.filtered_by.gitignored = true + item.filtered_by.show_anyway = show_anyway + ignored = ignored + 1 + else + not_ignored = not_ignored + 1 + end + end + log.trace("IGNORED: mark_ignored is complete, ignored:", ignored, ", not ignored:", not_ignored) + log.trace("================================================================================") + end + + local all_results = {} + if type(callback) == "function" then + local jobs = {} + local progress = 0 + for folder, folder_items in pairs(folders) do + local args = { "-C", folder, "check-ignore", "--stdin" } + local job = Job:new({ + command = "git", + args = args, + enabled_recording = true, + writer = folder_items, + on_start = function() + log.trace("IGNORED: Running async git with args: ", args) + end, + on_exit = function(self, code, _) + local result + if code ~= 0 then + log.debug("Failed to load ignored files for", state.path, ":", self:stderr_result()) + result = {} + else + result = self:result() + end + vim.list_extend(all_results, process_result(result)) + progress = progress + 1 + if progress == #jobs then + finalize(all_results) + callback(all_results) + end + end, + }) + table.insert(jobs, job) + end + + for _, job in ipairs(jobs) do + job:start() + end + else + for folder, folder_items in pairs(folders) do + local cmd = { "git", "-C", folder, "check-ignore", unpack(folder_items) } + log.trace("IGNORED: Running cmd: ", cmd) + local result = vim.fn.systemlist(cmd) + if vim.v.shell_error == 128 then + log.debug("Failed to load ignored files for", state.path, ":", result) + result = {} + end + vim.list_extend(all_results, process_result(result)) + end + finalize(all_results) + return all_results + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/git/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/git/init.lua new file mode 100644 index 000000000..863599be8 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/git/init.lua @@ -0,0 +1,13 @@ +local status = require("neo-tree.git.status") +local ignored = require("neo-tree.git.ignored") +local git_utils = require("neo-tree.git.utils") + +local M = { + get_repository_root = git_utils.get_repository_root, + is_ignored = ignored.is_ignored, + mark_ignored = ignored.mark_ignored, + status = status.status, + status_async = status.status_async, +} + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/git/status.lua b/bundle/neo-tree.nvim/lua/neo-tree/git/status.lua new file mode 100644 index 000000000..3427f9f74 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/git/status.lua @@ -0,0 +1,339 @@ +local utils = require("neo-tree.utils") +local events = require("neo-tree.events") +local Job = require("plenary.job") +local log = require("neo-tree.log") +local git_utils = require("neo-tree.git.utils") + +local M = {} + +local function get_simple_git_status_code(status) + -- Prioritze M then A over all others + if status:match("U") or status == "AA" or status == "DD" then + return "U" + elseif status:match("M") then + return "M" + elseif status:match("[ACR]") then + return "A" + elseif status:match("!$") then + return "!" + elseif status:match("?$") then + return "?" + else + local len = #status + while len > 0 do + local char = status:sub(len, len) + if char ~= " " then + return char + end + len = len - 1 + end + return status + end +end + +local function get_priority_git_status_code(status, other_status) + if not status then + return other_status + elseif not other_status then + return status + elseif status == "U" or other_status == "U" then + return "U" + elseif status == "?" or other_status == "?" then + return "?" + elseif status == "M" or other_status == "M" then + return "M" + elseif status == "A" or other_status == "A" then + return "A" + else + return status + end +end + +local parse_git_status_line = function(context, line) + context.lines_parsed = context.lines_parsed + 1 + if type(line) ~= "string" then + return + end + if #line < 4 then + return + end + local git_root = context.git_root + local git_status = context.git_status + local exclude_directories = context.exclude_directories + + local line_parts = vim.split(line, " ") + if #line_parts < 2 then + return + end + local status = line_parts[1] + local relative_path = line_parts[2] + + -- rename output is `R000 from/filename to/filename` + if status:match("^R") then + relative_path = line_parts[3] + end + + -- remove any " due to whitespace or utf-8 in the path + relative_path = relative_path:gsub('^"', ""):gsub('"$', "") + -- convert octal encoded lines to utf-8 + relative_path = git_utils.octal_to_utf8(relative_path) + + if utils.is_windows == true then + relative_path = utils.windowize_path(relative_path) + end + local absolute_path = utils.path_join(git_root, relative_path) + -- merge status result if there are results from multiple passes + local existing_status = git_status[absolute_path] + if existing_status then + local merged = "" + local i = 0 + while i < 2 do + i = i + 1 + local existing_char = #existing_status >= i and existing_status:sub(i, i) or "" + local new_char = #status >= i and status:sub(i, i) or "" + local merged_char = get_priority_git_status_code(existing_char, new_char) + merged = merged .. merged_char + end + status = merged + end + git_status[absolute_path] = status + + if not exclude_directories then + -- Now bubble this status up to the parent directories + local parts = utils.split(absolute_path, utils.path_separator) + table.remove(parts) -- pop the last part so we don't override the file's status + utils.reduce(parts, "", function(acc, part) + local path = acc .. utils.path_separator .. part + if utils.is_windows == true then + path = path:gsub("^" .. utils.path_separator, "") + end + local path_status = git_status[path] + local file_status = get_simple_git_status_code(status) + git_status[path] = get_priority_git_status_code(path_status, file_status) + return path + end) + end +end + +---Parse "git status" output for the current working directory. +---@base git ref base +---@exclude_directories boolean Whether to skip bubling up status to directories +---@path string Path to run the git status command in, defaults to cwd. +---@return table table Table with the path as key and the status as value. +---@return table, string|nil The git root for the specified path. +M.status = function(base, exclude_directories, path) + local git_root = git_utils.get_repository_root(path) + if not utils.truthy(git_root) then + return {} + end + + local C = git_root + local staged_cmd = { "git", "-C", C, "diff", "--staged", "--name-status", base, "--" } + local staged_ok, staged_result = utils.execute_command(staged_cmd) + if not staged_ok then + return {} + end + local unstaged_cmd = { "git", "-C", C, "diff", "--name-status" } + local unstaged_ok, unstaged_result = utils.execute_command(unstaged_cmd) + if not unstaged_ok then + return {} + end + local untracked_cmd = { "git", "-C", C, "ls-files", "--exclude-standard", "--others" } + local untracked_ok, untracked_result = utils.execute_command(untracked_cmd) + if not untracked_ok then + return {} + end + + local context = { + git_root = git_root, + git_status = {}, + exclude_directories = exclude_directories, + lines_parsed = 0, + } + + for _, line in ipairs(staged_result) do + parse_git_status_line(context, line) + end + for _, line in ipairs(unstaged_result) do + if line then + line = " " .. line + end + parse_git_status_line(context, line) + end + for _, line in ipairs(untracked_result) do + if line then + line = "? " .. line + end + parse_git_status_line(context, line) + end + + return context.git_status, git_root +end + +local function parse_lines_batch(context, job_complete_callback) + local i, batch_size = 0, context.batch_size + + if context.lines_total == nil then + -- first time through, get the total number of lines + context.lines_total = math.min(context.max_lines, #context.lines) + context.lines_parsed = 0 + if context.lines_total == 0 then + if type(job_complete_callback) == "function" then + job_complete_callback() + end + return + end + end + batch_size = math.min(context.batch_size, context.lines_total - context.lines_parsed) + + while i < batch_size do + i = i + 1 + parse_git_status_line(context, context.lines[context.lines_parsed + 1]) + end + + if context.lines_parsed >= context.lines_total then + if type(job_complete_callback) == "function" then + job_complete_callback() + end + else + -- add small delay so other work can happen + vim.defer_fn(function() + parse_lines_batch(context, job_complete_callback) + end, context.batch_delay) + end +end + +M.status_async = function(path, base, opts) + git_utils.get_repository_root(path, function(git_root) + if utils.truthy(git_root) then + log.trace("git.status.status_async called") + else + log.trace("status_async: not a git folder: ", path) + return false + end + + local event_id = "git_status_" .. git_root + local context = { + git_root = git_root, + git_status = {}, + exclude_directories = false, + lines = {}, + lines_parsed = 0, + batch_size = opts.batch_size or 1000, + batch_delay = opts.batch_delay or 10, + max_lines = opts.max_lines or 100000, + } + + local should_process = function(err, line, job, err_msg) + if vim.v.dying > 0 or vim.v.exiting ~= vim.NIL then + job:shutdown() + return false + end + if err and err > 0 then + log.error(err_msg, err, line) + return false + end + return true + end + + local job_complete_callback = function() + utils.debounce(event_id, nil, nil, nil, utils.debounce_action.COMPLETE_ASYNC_JOB) + vim.schedule(function() + events.fire_event(events.GIT_STATUS_CHANGED, { + git_root = context.git_root, + git_status = context.git_status, + }) + end) + end + + local parse_lines = vim.schedule_wrap(function() + parse_lines_batch(context, job_complete_callback) + end) + + utils.debounce(event_id, function() + local staged_job = Job:new({ + command = "git", + args = { "-C", git_root, "diff", "--staged", "--name-status", base, "--" }, + enable_recording = false, + maximium_results = context.max_lines, + on_stdout = vim.schedule_wrap(function(err, line, job) + if should_process(err, line, job, "status_async staged error:") then + table.insert(context.lines, line) + end + end), + on_stderr = function(err, line) + if err and err > 0 then + log.error("status_async staged error: ", err, line) + end + end, + }) + + local unstaged_job = Job:new({ + command = "git", + args = { "-C", git_root, "diff", "--name-status" }, + enable_recording = false, + maximium_results = context.max_lines, + on_stdout = vim.schedule_wrap(function(err, line, job) + if should_process(err, line, job, "status_async unstaged error:") then + if line then + line = " " .. line + end + table.insert(context.lines, line) + end + end), + on_stderr = function(err, line) + if err and err > 0 then + log.error("status_async unstaged error: ", err, line) + end + end, + }) + + local untracked_job = Job:new({ + command = "git", + args = { "-C", git_root, "ls-files", "--exclude-standard", "--others" }, + enable_recording = false, + maximium_results = context.max_lines, + on_stdout = vim.schedule_wrap(function(err, line, job) + if should_process(err, line, job, "status_async untracked error:") then + if line then + line = "? " .. line + end + table.insert(context.lines, line) + end + end), + on_stderr = function(err, line) + if err and err > 0 then + log.error("status_async untracked error: ", err, line) + end + end, + }) + + Job:new({ + command = "git", + args = { + "-C", + git_root, + "config", + "--get", + "status.showUntrackedFiles", + }, + enabled_recording = true, + on_exit = function(self, _, _) + local result = self:result() + log.debug("git status.showUntrackedFiles =", result[1]) + if result[1] == "no" then + unstaged_job:after(parse_lines) + Job.chain(staged_job, unstaged_job) + else + untracked_job:after(parse_lines) + Job.chain(staged_job, unstaged_job, untracked_job) + end + end, + }):start() + end, 1000, utils.debounce_strategy.CALL_FIRST_AND_LAST, utils.debounce_action.START_ASYNC_JOB) + + return true + end) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/git/utils.lua b/bundle/neo-tree.nvim/lua/neo-tree/git/utils.lua new file mode 100644 index 000000000..d237de8a4 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/git/utils.lua @@ -0,0 +1,65 @@ +local Job = require("plenary.job") + +local utils = require("neo-tree.utils") +local log = require("neo-tree.log") + +local M = {} + +M.get_repository_root = function(path, callback) + local args = { "rev-parse", "--show-toplevel" } + if utils.truthy(path) then + args = { "-C", path, "rev-parse", "--show-toplevel" } + end + if type(callback) == "function" then + Job:new({ + command = "git", + args = args, + enabled_recording = true, + on_exit = function(self, code, _) + if code ~= 0 then + log.trace("GIT ROOT ERROR ", self:stderr_result()) + callback(nil) + return + end + local git_root = self:result()[1] + + if utils.is_windows then + git_root = utils.windowize_path(git_root) + end + + log.trace("GIT ROOT for '", path, "' is '", git_root, "'") + callback(git_root) + end, + }):start() + else + local ok, git_root = utils.execute_command({ "git", unpack(args) }) + if not ok then + log.trace("GIT ROOT ERROR ", git_root) + return nil + end + git_root = git_root[1] + + if utils.is_windows then + git_root = utils.windowize_path(git_root) + end + + log.trace("GIT ROOT for '", path, "' is '", git_root, "'") + return git_root + end +end + +local convert_octal_char = function(octal) + return string.char(tonumber(octal, 8)) +end + +M.octal_to_utf8 = function(text) + -- git uses octal encoding for utf-8 filepaths, convert octal back to utf-8 + local success, converted = pcall(string.gsub, text, "\\([0-7][0-7][0-7])", convert_octal_char) + if success then + return converted + else + return text + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/log.lua b/bundle/neo-tree.nvim/lua/neo-tree/log.lua new file mode 100644 index 000000000..a0912fc4c --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/log.lua @@ -0,0 +1,189 @@ +-- log.lua +-- +-- Inspired by rxi/log.lua +-- Modified by tjdevries and can be found at github.com/tjdevries/vlog.nvim +-- +-- This library is free software; you can redistribute it and/or modify it +-- under the terms of the MIT license. See LICENSE for details. + +local vim = vim +-- User configuration section +local default_config = { + -- Name of the plugin. Prepended to log messages + plugin = "neo-tree.nvim", + + -- Should print the output to neovim while running + use_console = true, + + -- Should highlighting be used in console (using echohl) + highlights = true, + + -- Should write to a file + use_file = false, + + -- Any messages above this level will be logged. + level = "info", + + -- Level configuration + modes = { + { name = "trace", hl = "None", level = vim.log.levels.TRACE }, + { name = "debug", hl = "None", level = vim.log.levels.DEBGUG }, + { name = "info", hl = "None", level = vim.log.levels.INFO }, + { name = "warn", hl = "WarningMsg", level = vim.log.levels.WARN }, + { name = "error", hl = "ErrorMsg", level = vim.log.levels.ERROR }, + { name = "fatal", hl = "ErrorMsg", level = vim.log.levels.ERROR }, + }, + + -- Can limit the number of decimals displayed for floats + float_precision = 0.01, +} + +-- {{{ NO NEED TO CHANGE +local log = {} + +local unpack = unpack or table.unpack + +local notify = function(message, level_config) + if type(vim.notify) == "table" then + -- probably using nvim-notify + vim.notify(message, level_config.level, { title = "Neo-tree" }) + else + local nameupper = level_config.name:upper() + local console_string = string.format("[Neo-tree %s] %s", nameupper, message) + vim.notify(console_string, level_config.level) + end +end + +log.new = function(config, standalone) + config = vim.tbl_deep_extend("force", default_config, config) + + local outfile = + string.format("%s/%s.log", vim.api.nvim_call_function("stdpath", { "data" }), config.plugin) + + local obj + if standalone then + obj = log + else + obj = {} + end + obj.outfile = outfile + + obj.use_file = function(file, quiet) + if file == false then + if not quiet then + obj.info("[neo-tree] Logging to file disabled") + end + config.use_file = false + else + if type(file) == "string" then + obj.outfile = file + else + obj.outfile = outfile + end + config.use_file = true + if not quiet then + obj.info("[neo-tree] Logging to file: " .. obj.outfile) + end + end + end + + local levels = {} + for i, v in ipairs(config.modes) do + levels[v.name] = i + end + + obj.set_level = function(level) + if levels[level] then + if config.level ~= level then + config.level = level + end + else + notify("Invalid log level: " .. level, config.modes[5]) + end + end + + local round = function(x, increment) + increment = increment or 1 + x = x / increment + return (x > 0 and math.floor(x + 0.5) or math.ceil(x - 0.5)) * increment + end + + local make_string = function(...) + local t = {} + for i = 1, select("#", ...) do + local x = select(i, ...) + + if type(x) == "number" and config.float_precision then + x = tostring(round(x, config.float_precision)) + elseif type(x) == "table" then + x = vim.inspect(x) + if #x > 300 then + x = x:sub(1, 300) .. "..." + end + else + x = tostring(x) + end + + t[#t + 1] = x + end + return table.concat(t, " ") + end + + local log_at_level = function(level, level_config, message_maker, ...) + -- Return early if we're below the config.level + if level < levels[config.level] then + return + end + -- Ignnore this if vim is exiting + if vim.v.dying > 0 or vim.v.exiting ~= vim.NIL then + return + end + local nameupper = level_config.name:upper() + + local msg = message_maker(...) + local info = debug.getinfo(2, "Sl") + local lineinfo = info.short_src .. ":" .. info.currentline + + -- Output to log file + if config.use_file then + local str = string.format("[%-6s%s] %s: %s\n", nameupper, os.date(), lineinfo, msg) + local fp = io.open(obj.outfile, "a") + if fp then + fp:write(str) + fp:close() + else + print("[neo-tree] Could not open log file: " .. obj.outfile) + end + end + + -- Output to console + if config.use_console and level > 2 then + vim.schedule(function() + notify(msg, level_config) + end) + end + end + + for i, x in ipairs(config.modes) do + obj[x.name] = function(...) + return log_at_level(i, x, make_string, ...) + end + + obj[("fmt_%s"):format(x.name)] = function() + return log_at_level(i, x, function(...) + local passed = { ... } + local fmt = table.remove(passed, 1) + local inspected = {} + for _, v in ipairs(passed) do + table.insert(inspected, vim.inspect(v)) + end + return string.format(fmt, unpack(inspected)) + end) + end + end +end + +log.new(default_config, true) +-- }}} + +return log diff --git a/bundle/neo-tree.nvim/lua/neo-tree/setup/deprecations.lua b/bundle/neo-tree.nvim/lua/neo-tree/setup/deprecations.lua new file mode 100644 index 000000000..e1f0e25d9 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/setup/deprecations.lua @@ -0,0 +1,93 @@ +local utils = require("neo-tree.utils") + +local M = {} + +local migrations = {} + +M.show_migrations = function() + if #migrations > 0 then + for i, message in ipairs(migrations) do + migrations[i] = " * " .. message + end + table.insert( + migrations, + 1, + "# Neo-tree configuration has been updated. Please review the changes below." + ) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, migrations) + vim.api.nvim_buf_set_option(buf, "buftype", "nofile") + vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe") + vim.api.nvim_buf_set_option(buf, "buflisted", false) + vim.api.nvim_buf_set_option(buf, "swapfile", false) + vim.api.nvim_buf_set_option(buf, "modifiable", false) + vim.api.nvim_buf_set_option(buf, "filetype", "markdown") + vim.api.nvim_buf_set_name(buf, "Neo-tree migrations") + vim.defer_fn(function() + vim.cmd(string.format("%ssplit", #migrations)) + vim.api.nvim_win_set_buf(0, buf) + end, 100) + end +end + +M.migrate = function(config) + migrations = {} + + local moved = function(old, new, converter) + local existing = utils.get_value(config, old) + if type(existing) ~= "nil" then + if type(converter) == "function" then + existing = converter(existing) + end + utils.set_value(config, new, existing) + config[old] = nil + migrations[#migrations + 1] = + string.format("The `%s` option has been deprecated, please use `%s` instead.", old, new) + end + end + + local removed = function(key) + local value = utils.get_value(config, key) + if type(value) ~= "nil" then + utils.set_value(config, key, nil) + migrations[#migrations + 1] = string.format("The `%s` option has been removed.", key) + end + end + + local renamed_value = function(key, old_value, new_value) + local value = utils.get_value(config, key) + if value == old_value then + utils.set_value(config, key, new_value) + migrations[#migrations + 1] = + string.format("The `%s=%s` option has been renamed to `%s`.", key, old_value, new_value) + end + end + + local opposite = function(value) + return not value + end + + local tab_to_source_migrator = function(labels) + local converted_sources = {} + for entry, label in pairs(labels) do + table.insert(converted_sources, { source = entry, display_name = label }) + end + return converted_sources + end + + moved("filesystem.filters", "filesystem.filtered_items") + moved("filesystem.filters.show_hidden", "filesystem.filtered_items.hide_dotfiles", opposite) + moved("filesystem.filters.respect_gitignore", "filesystem.filtered_items.hide_gitignored") + moved("open_files_do_not_replace_filetypes", "open_files_do_not_replace_types") + moved("source_selector.tab_labels", "source_selector.sources", tab_to_source_migrator) + removed("filesystem.filters.gitignore_source") + removed("filesystem.filter_items.gitignore_source") + renamed_value("filesystem.hijack_netrw_behavior", "open_split", "open_current") + for _, source in ipairs({ "filesystem", "buffers", "git_status" }) do + renamed_value(source .. "window.position", "split", "current") + end + + return migrations +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/setup/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/setup/init.lua new file mode 100644 index 000000000..eb0375a39 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/setup/init.lua @@ -0,0 +1,739 @@ +local utils = require("neo-tree.utils") +local defaults = require("neo-tree.defaults") +local mapping_helper = require("neo-tree.setup.mapping-helper") +local events = require("neo-tree.events") +local log = require("neo-tree.log") +local file_nesting = require("neo-tree.sources.common.file-nesting") +local highlights = require("neo-tree.ui.highlights") +local manager = require("neo-tree.sources.manager") +local netrw = require("neo-tree.setup.netrw") + +local M = {} + +local normalize_mappings = function(config) + if config == nil then + return false + end + local mappings = utils.get_value(config, "window.mappings", nil) + if mappings then + local fixed = mapping_helper.normalize_map(mappings) + config.window.mappings = fixed + return true + else + return false + end +end + +local events_setup = false +local define_events = function() + if events_setup then + return + end + + events.define_event(events.FS_EVENT, { + debounce_frequency = 100, + debounce_strategy = utils.debounce_strategy.CALL_LAST_ONLY, + }) + + local v = vim.version() + local diag_autocmd = "DiagnosticChanged" + if v.major < 1 and v.minor < 6 then + diag_autocmd = "User LspDiagnosticsChanged" + end + events.define_autocmd_event(events.VIM_DIAGNOSTIC_CHANGED, { diag_autocmd }, 500, function(args) + args.diagnostics_lookup = utils.get_diagnostic_counts() + return args + end) + + + + local update_opened_buffers = function(args) + args.opened_buffers = utils.get_opened_buffers() + return args + end + + events.define_autocmd_event(events.VIM_AFTER_SESSION_LOAD, { "SessionLoadPost" }, 200) + events.define_autocmd_event(events.VIM_BUFFER_ADDED, { "BufAdd" }, 200, update_opened_buffers) + events.define_autocmd_event( + events.VIM_BUFFER_DELETED, + { "BufDelete" }, + 200, + update_opened_buffers + ) + events.define_autocmd_event(events.VIM_BUFFER_ENTER, { "BufEnter", "BufWinEnter" }, 0) + events.define_autocmd_event( + events.VIM_BUFFER_MODIFIED_SET, + { "BufModifiedSet" }, + 0, + update_opened_buffers + ) + events.define_autocmd_event(events.VIM_COLORSCHEME, { "ColorScheme" }, 0) + events.define_autocmd_event(events.VIM_CURSOR_MOVED, { "CursorMoved" }, 100) + events.define_autocmd_event(events.VIM_DIR_CHANGED, { "DirChanged" }, 200, nil, true) + events.define_autocmd_event(events.VIM_INSERT_LEAVE, { "InsertLeave" }, 200) + events.define_autocmd_event(events.VIM_LEAVE, { "VimLeavePre" }) + events.define_autocmd_event(events.VIM_RESIZED, { "VimResized" }, 100) + events.define_autocmd_event(events.VIM_TAB_CLOSED, { "TabClosed" }) + events.define_autocmd_event(events.VIM_TERMINAL_ENTER, { "TermEnter" }, 0) + events.define_autocmd_event(events.VIM_TEXT_CHANGED_NORMAL, { "TextChanged" }, 200) + events.define_autocmd_event(events.VIM_WIN_CLOSED, { "WinClosed" }) + events.define_autocmd_event(events.VIM_WIN_ENTER, { "WinEnter" }, 0, nil, true) + + events.define_autocmd_event(events.GIT_EVENT, { "User FugitiveChanged" }, 100) + events.define_event(events.GIT_STATUS_CHANGED, { debounce_frequency = 0 }) + events_setup = true + + events.subscribe({ + event = events.VIM_LEAVE, + handler = function() + events.clear_all_events() + end, + }) + + events.subscribe({ + event = events.VIM_RESIZED, + handler = function() + require("neo-tree.ui.renderer").update_floating_window_layouts() + end, + }) +end + +local prior_window_options = {} + +--- Store the current window options so we can restore them when we close the tree. +--- @param winid number | nil The window id to store the options for, defaults to current window +local store_local_window_settings = function(winid) + winid = winid or vim.api.nvim_get_current_win() + local neo_tree_settings_applied, _ = + pcall(vim.api.nvim_win_get_var, winid, "neo_tree_settings_applied") + if neo_tree_settings_applied then + -- don't store our own window settings + return + end + prior_window_options[tostring(winid)] = { + cursorline = vim.wo.cursorline, + cursorlineopt = vim.wo.cursorlineopt, + foldcolumn = vim.wo.foldcolumn, + wrap = vim.wo.wrap, + list = vim.wo.list, + spell = vim.wo.spell, + number = vim.wo.number, + relativenumber = vim.wo.relativenumber, + winhighlight = vim.wo.winhighlight, + } +end + +--- Restore the window options for the current window +--- @param winid number | nil The window id to restore the options for, defaults to current window +local restore_local_window_settings = function(winid) + winid = winid or vim.api.nvim_get_current_win() + -- return local window settings to their prior values + local wo = prior_window_options[tostring(winid)] + if wo then + vim.wo.cursorline = wo.cursorline + vim.wo.cursorlineopt = wo.cursorlineopt + vim.wo.foldcolumn = wo.foldcolumn + vim.wo.wrap = wo.wrap + vim.wo.list = wo.list + vim.wo.spell = wo.spell + vim.wo.number = wo.number + vim.wo.relativenumber = wo.relativenumber + vim.wo.winhighlight = wo.winhighlight + log.debug("Window settings restored") + vim.api.nvim_win_set_var(0, "neo_tree_settings_applied", false) + else + log.debug("No window settings to restore") + end +end + +local last_buffer_enter_filetype = nil +M.buffer_enter_event = function() + -- if it is a neo-tree window, just set local options + if vim.bo.filetype == "neo-tree" then + if last_buffer_enter_filetype == "neo-tree" then + -- we've switched to another neo-tree window + events.fire_event(events.NEO_TREE_BUFFER_LEAVE) + else + store_local_window_settings() + end + vim.cmd([[ + setlocal cursorline + setlocal cursorlineopt=line + setlocal nowrap + setlocal nolist nospell nonumber norelativenumber + ]]) + + local winhighlight = + "Normal:NeoTreeNormal,NormalNC:NeoTreeNormalNC,SignColumn:NeoTreeSignColumn,CursorLine:NeoTreeCursorLine,FloatBorder:NeoTreeFloatBorder,StatusLine:NeoTreeStatusLine,StatusLineNC:NeoTreeStatusLineNC,VertSplit:NeoTreeVertSplit,EndOfBuffer:NeoTreeEndOfBuffer" + if vim.version().minor >= 7 then + vim.cmd("setlocal winhighlight=" .. winhighlight .. ",WinSeparator:NeoTreeWinSeparator") + else + vim.cmd("setlocal winhighlight=" .. winhighlight) + end + + events.fire_event(events.NEO_TREE_BUFFER_ENTER) + last_buffer_enter_filetype = vim.bo.filetype + vim.api.nvim_win_set_var(0, "neo_tree_settings_applied", true) + return + end + + if vim.bo.filetype == "neo-tree-popup" then + vim.cmd([[ + setlocal winhighlight=Normal:NeoTreeFloatNormal,FloatBorder:NeoTreeFloatBorder + setlocal nolist nospell nonumber norelativenumber + ]]) + events.fire_event(events.NEO_TREE_POPUP_BUFFER_ENTER) + last_buffer_enter_filetype = vim.bo.filetype + return + end + + if last_buffer_enter_filetype == "neo-tree" then + events.fire_event(events.NEO_TREE_BUFFER_LEAVE) + end + if last_buffer_enter_filetype == "neo-tree-popup" then + events.fire_event(events.NEO_TREE_POPUP_BUFFER_LEAVE) + end + last_buffer_enter_filetype = vim.bo.filetype + + -- there is nothing more we want to do with floating windows + if utils.is_floating() then + return + end + + -- if vim is trying to open a dir, then we hijack it + if netrw.hijack() then + return + end + + -- For all others, make sure another buffer is not hijacking our window + -- ..but not if the position is "current" + local prior_buf = vim.fn.bufnr("#") + if prior_buf < 1 then + return + end + local winid = vim.api.nvim_get_current_win() + local prior_type = vim.api.nvim_buf_get_option(prior_buf, "filetype") + if prior_type == "neo-tree" then + local success, position = pcall(vim.api.nvim_buf_get_var, prior_buf, "neo_tree_position") + if not success then + -- just bail out now, the rest of these lookups will probably fail too. + return + end + + if position == "current" then + -- nothing to do here, files are supposed to open in same window + return + end + + local current_tabid = vim.api.nvim_get_current_tabpage() + local neo_tree_tabid = vim.api.nvim_buf_get_var(prior_buf, "neo_tree_tabid") + if neo_tree_tabid ~= current_tabid then + -- This a new tab, so the alternate being neo-tree doesn't matter. + return + end + local neo_tree_winid = vim.api.nvim_buf_get_var(prior_buf, "neo_tree_winid") + local current_winid = vim.api.nvim_get_current_win() + if neo_tree_winid ~= current_winid then + -- This is not the neo-tree window, so the alternate being neo-tree doesn't matter. + return + end + + local bufname = vim.api.nvim_buf_get_name(0) + log.debug("redirecting buffer " .. bufname .. " to new split") + vim.cmd("b#") + -- Using schedule at this point fixes problem with syntax + -- highlighting in the buffer. I also prevents errors with diagnostics + -- trying to work with the buffer as it's being closed. + vim.schedule(function() + -- try to delete the buffer, only because if it was new it would take + -- on options from the neo-tree window that are undesirable. + pcall(vim.cmd, "bdelete " .. bufname) + local fake_state = { + window = { + position = position, + }, + } + utils.open_file(fake_state, bufname) + end) + end +end + +M.win_enter_event = function() + local win_id = vim.api.nvim_get_current_win() + if utils.is_floating(win_id) then + return + end + + -- if the new win is not a floating window, make sure all neo-tree floats are closed + manager.close_all("float") + + if M.config.close_if_last_window then + local tabid = vim.api.nvim_get_current_tabpage() + local wins = utils.get_value(M, "config.prior_windows", {})[tabid] + local prior_exists = utils.truthy(wins) + local non_floating_wins = vim.tbl_filter(function(win) + return not utils.is_floating(win) + end, vim.api.nvim_tabpage_list_wins(tabid)) + local win_count = #non_floating_wins + log.trace("checking if last window") + log.trace("prior window exists = ", prior_exists) + log.trace("win_count: ", win_count) + if prior_exists and win_count == 1 and vim.o.filetype == "neo-tree" then + local position = vim.api.nvim_buf_get_var(0, "neo_tree_position") + local source = vim.api.nvim_buf_get_var(0, "neo_tree_source") + if position ~= "current" then + -- close_if_last_window just doesn't make sense for a split style + log.trace("last window, closing") + local state = require("neo-tree.sources.manager").get_state(source) + if state == nil then + return + end + local mod = utils.get_opened_buffers() + log.debug("close_if_last_window, modified files found: ", vim.inspect(mod)) + for filename, buf_info in pairs(mod) do + if buf_info.modified then + local buf_name, message + if vim.startswith(filename, "[No Name]#") then + buf_name = string.sub(filename, 11) + message = "Cannot close because an unnamed buffer is modified. Please save or discard this file." + else + buf_name = filename + message = "Cannot close because one of the files is modified. Please save or discard changes." + end + log.trace("close_if_last_window, showing unnamed modified buffer: ", filename) + vim.schedule(function() + log.warn(message) + vim.cmd("rightbelow vertical split") + vim.api.nvim_win_set_width(win_id, state.window.width or 40) + vim.cmd("b" .. buf_name) + end) + return + end + end + vim.cmd("q!") + return + end + end + end + + if vim.o.filetype == "neo-tree" then + local _, position = pcall(vim.api.nvim_buf_get_var, 0, "neo_tree_position") + if position == "current" then + -- make sure the buffer wasn't moved to a new window + local neo_tree_winid = vim.api.nvim_buf_get_var(0, "neo_tree_winid") + local current_winid = vim.api.nvim_get_current_win() + local current_bufnr = vim.api.nvim_get_current_buf() + if neo_tree_winid ~= current_winid then + -- At this point we know that either the neo-tree window was split, + -- or the neo-tree buffer is being shown in another window for some other reason. + -- Sometime the split is just the first step in the process of opening somethig else, + -- so instead of fixing this right away, we add a short delay and check back again to see + -- if the buffer is still in this window. + local old_state = manager.get_state("filesystem", nil, neo_tree_winid) + vim.schedule(function() + local bufnr = vim.api.nvim_get_current_buf() + if bufnr ~= current_bufnr then + -- The neo-tree buffer was replaced with something else, so we don't need to do anything. + log.trace("neo-tree buffer replaced with something else - no further action required") + return + end + -- create a new tree for this window + local state = manager.get_state("filesystem", nil, current_winid) + state.path = old_state.path + state.current_position = "current" + local renderer = require("neo-tree.ui.renderer") + state.force_open_folders = renderer.get_expanded_nodes(old_state.tree) + require("neo-tree.sources.filesystem")._navigate_internal(state, nil, nil, nil, false) + end) + return + end + end + -- it's a neo-tree window, ignore + return + end + + M.config.prior_windows = M.config.prior_windows or {} + + local tabid = vim.api.nvim_get_current_tabpage() + local tab_windows = M.config.prior_windows[tabid] + if tab_windows == nil then + tab_windows = {} + M.config.prior_windows[tabid] = tab_windows + end + table.insert(tab_windows, win_id) + + -- prune the history when it gets too big + if #tab_windows > 100 then + local new_array = {} + local win_count = #tab_windows + for i = 80, win_count do + table.insert(new_array, tab_windows[i]) + end + M.config.prior_windows[tabid] = new_array + end +end + +M.set_log_level = function(level) + log.set_level(level) +end + +local function merge_global_components_config(components, config) + local indent_exists = false + local merged_components = {} + local do_merge + + do_merge = function(component) + local name = component[1] + if type(name) == "string" then + if name == "indent" then + indent_exists = true + end + local merged = { name } + local global_config = config.default_component_configs[name] + if global_config then + for k, v in pairs(global_config) do + merged[k] = v + end + end + for k, v in pairs(component) do + merged[k] = v + end + if name == "container" then + for i, child in ipairs(component.content) do + merged.content[i] = do_merge(child) + end + end + return merged + else + log.error("component name is the wrong type", component) + end + end + + for _, component in ipairs(components) do + local merged = do_merge(component) + table.insert(merged_components, merged) + end + + -- If the indent component is not specified, then add it. + -- We do this because it used to be implicitly added, so we don't want to + -- break any existing configs. + if not indent_exists then + local indent = { "indent" } + for k, v in pairs(config.default_component_configs.indent or {}) do + indent[k] = v + end + table.insert(merged_components, 1, indent) + end + return merged_components +end + +local merge_renderers = function(default_config, source_default_config, user_config) + -- This can't be a deep copy/merge. If a renderer is specified in the target it completely + -- replaces the base renderer. + + if source_default_config == nil then + -- first override the default config global renderer with the user's global renderers + for name, renderer in pairs(user_config.renderers or {}) do + log.debug("overriding global renderer for " .. name) + default_config.renderers[name] = renderer + end + else + -- then override the global renderers with the source specific renderers + source_default_config.renderers = source_default_config.renderers or {} + for name, renderer in pairs(default_config.renderers or {}) do + if source_default_config.renderers[name] == nil then + log.debug("overriding source renderer for " .. name) + local r = {} + -- Only copy components that exist in the target source. + -- This alllows us to specify global renderers that include components from all sources, + -- even if some of those components are not universal + for _, value in ipairs(renderer) do + if value[1] and source_default_config.components[value[1]] ~= nil then + table.insert(r, value) + end + end + source_default_config.renderers[name] = r + end + end + + -- if user sets renderers, completely wipe the default ones + local source_name = source_default_config.name + for name, _ in pairs(source_default_config.renderers) do + local user = utils.get_value(user_config, source_name .. ".renderers." .. name) + if user then + source_default_config.renderers[name] = nil + end + end + end +end + +M.merge_config = function(user_config, is_auto_config) + local default_config = vim.deepcopy(defaults) + user_config = vim.deepcopy(user_config or {}) + + local migrations = require("neo-tree.setup.deprecations").migrate(user_config) + if #migrations > 0 then + -- defer to make sure it is the last message printed + vim.defer_fn(function() + vim.cmd( + "echohl WarningMsg | echo 'Some options have changed, please run `:Neotree migrations` to see the changes' | echohl NONE" + ) + end, 50) + end + + if user_config.log_level ~= nil then + M.set_log_level(user_config.log_level) + end + log.use_file(user_config.log_to_file, true) + log.debug("setup") + + events.clear_all_events() + define_events() + + -- Prevent accidentally opening another file in the neo-tree window. + events.subscribe({ + event = events.VIM_BUFFER_ENTER, + handler = M.buffer_enter_event, + }) + + -- Setup autocmd for neo-tree BufLeave, to restore window settings. + -- This is set to happen just before leaving the window. + -- The patterns used should ensure it only runs in neo-tree windows where position = "current" + local augroup = vim.api.nvim_create_augroup("NeoTree_BufLeave", { clear = true }) + local bufleave = function(data) + -- Vim patterns in autocmds are not quite precise enough + -- so we are doing a second stage filter in lua + local pattern = "neo%-tree [^ ]+ %[1%d%d%d%]" + if string.match(data.file, pattern) then + restore_local_window_settings() + end + end + vim.api.nvim_create_autocmd({ "BufWinLeave" }, { + group = augroup, + pattern = "neo-tree *", + callback = bufleave, + }) + + if user_config.event_handlers ~= nil then + for _, handler in ipairs(user_config.event_handlers) do + events.subscribe(handler) + end + end + + highlights.setup() + + -- used to either limit the sources that or loaded, or add extra external sources + local all_sources = {} + local all_source_names = {} + for _, source in ipairs(user_config.sources or default_config.sources) do + local parts = utils.split(source, ".") + local name = parts[#parts] + local is_internal_ns, is_external_ns = false, false + local module + + if #parts == 1 then + -- might be a module name in the internal namespace + is_internal_ns, module = pcall(require, "neo-tree.sources." .. source) + end + if is_internal_ns then + name = module.name or name + all_sources[name] = "neo-tree.sources." .. name + else + -- fully qualified module name + -- or just a root level module name + is_external_ns, module = pcall(require, source) + if is_external_ns then + name = module.name or name + all_sources[name] = source + else + log.error("Source module not found", source) + name = nil + end + end + if name then + default_config[name] = module.default_config or default_config[name] + table.insert(all_source_names, name) + end + end + log.debug("Sources to load: ", vim.inspect(all_sources)) + require("neo-tree.command.parser").setup(all_source_names) + + -- setup the default values for all sources + normalize_mappings(default_config) + normalize_mappings(user_config) + merge_renderers(default_config, nil, user_config) + + for source_name, mod_root in pairs(all_sources) do + local module = require(mod_root) + default_config[source_name] = default_config[source_name] + or { + renderers = {}, + components = {}, + } + local source_default_config = default_config[source_name] + source_default_config.components = module.components or require(mod_root .. ".components") + source_default_config.commands = module.commands or require(mod_root .. ".commands") + source_default_config.name = source_name + source_default_config.display_name = module.display_name or source_default_config.name + + if user_config.use_default_mappings == false then + default_config.window.mappings = {} + source_default_config.window.mappings = {} + end + -- Make sure all the mappings are normalized so they will merge properly. + normalize_mappings(source_default_config) + normalize_mappings(user_config[source_name]) + -- merge the global config with the source specific config + source_default_config.window = vim.tbl_deep_extend( + "force", + default_config.window or {}, + source_default_config.window or {}, + user_config.window or {} + ) + + merge_renderers(default_config, source_default_config, user_config) + + --validate the window.position + local pos_key = source_name .. ".window.position" + local position = utils.get_value(user_config, pos_key, "left", true) + local valid_positions = { + left = true, + right = true, + top = true, + bottom = true, + float = true, + current = true, + } + if not valid_positions[position] then + log.error("Invalid value for ", pos_key, ": ", position) + user_config[source_name].window.position = "left" + end + end + --print(vim.inspect(default_config.filesystem)) + + -- Moving user_config.sources to user_config.orig_sources + user_config.orig_sources = user_config.sources and user_config.sources or {} + + -- apply the users config + M.config = vim.tbl_deep_extend("force", default_config, user_config) + + -- RE: 873, fixes issue with invalid source checking by overriding + -- source table with name table + -- Setting new "sources" to be the parsed names of the sources + M.config.sources = all_source_names + + if ( M.config.source_selector.winbar or M.config.source_selector.statusline ) + and M.config.source_selector.sources + and not user_config.default_source then + -- Set the default source to the head of these + -- This resolves some weirdness with the source selector having + -- a different "head" item than our current default. + -- Removing this line makes Neo-tree show the "filesystem" + -- source instead of whatever the first item in the config is. + -- Probably don't remove this unless you have a better fix for that + M.config.default_source = M.config.source_selector.sources[1].source + end + -- Check if the default source is not included in config.sources + -- log a warning and then "pick" the first in the sources list + local match = false + for _, source in ipairs(M.config.sources) do + if source == M.config.default_source then + match = true + break + end + end + if not match then + M.config.default_source = M.config.sources[1] + log.warn(string.format("Invalid default source found in configuration. Using first available source: %s", M.config.default_source)) + end + + if not M.config.enable_git_status then + M.config.git_status_async = false + end + + -- Validate that the source_selector.sources are all available and if any + -- aren't, remove them + local source_selector_sources = {} + for _, ss_source in ipairs(M.config.source_selector.sources or {}) do + local source_match = false + for _, source in ipairs(M.config.sources) do + if ss_source.source == source then + source_match = true + break + end + end + if source_match then + table.insert(source_selector_sources, ss_source) + else + log.debug(string.format("Unable to locate Neo-tree extension %s", ss_source.source)) + end + end + M.config.source_selector.sources = source_selector_sources + + file_nesting.setup(M.config.nesting_rules) + + for source_name, mod_root in pairs(all_sources) do + for name, rndr in pairs(M.config[source_name].renderers) do + M.config[source_name].renderers[name] = merge_global_components_config(rndr, M.config) + end + local module = require(mod_root) + if M.config.commands then + M.config[source_name].commands = + vim.tbl_extend("keep", M.config[source_name].commands or {}, M.config.commands) + end + manager.setup(source_name, M.config[source_name], M.config, module) + manager.redraw(source_name) + end + + if M.config.auto_clean_after_session_restore then + require("neo-tree.ui.renderer").clean_invalid_neotree_buffers(false) + events.subscribe({ + event = events.VIM_AFTER_SESSION_LOAD, + handler = function() + require("neo-tree.ui.renderer").clean_invalid_neotree_buffers(true) + end, + }) + end + + events.subscribe({ + event = events.VIM_COLORSCHEME, + handler = highlights.setup, + id = "neo-tree-highlight", + }) + + events.subscribe({ + event = events.VIM_WIN_ENTER, + handler = M.win_enter_event, + id = "neo-tree-win-enter", + }) + + --Dispose ourselves if the tab closes + events.subscribe({ + event = events.VIM_TAB_CLOSED, + handler = function(args) + local tabnr = tonumber(args.afile) + log.debug("VIM_TAB_CLOSED: disposing state for tabnr", tabnr) + -- Internally we use tabids to track state but is tabnr of a tab that has already been + -- closed so there is no way to get its tabid. Instead dispose all tabs that are no longer valid. + -- Must be scheduled because nvim_tabpage_is_valid does not work inside TabClosed event callback. + vim.schedule_wrap(manager.dispose_invalid_tabs)() + end, + }) + + --Dispose ourselves if the tab closes + events.subscribe({ + event = events.VIM_WIN_CLOSED, + handler = function(args) + local winid = tonumber(args.afile) + log.debug("VIM_WIN_CLOSED: disposing state for window", winid) + manager.dispose_window(winid) + end, + }) + + local rt = utils.get_value(M.config, "resize_timer_interval", 50, true) + require("neo-tree.ui.renderer").resize_timer_interval = rt + + return M.config +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/setup/mapping-helper.lua b/bundle/neo-tree.nvim/lua/neo-tree/setup/mapping-helper.lua new file mode 100644 index 000000000..79fc39105 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/setup/mapping-helper.lua @@ -0,0 +1,62 @@ +local utils = require("neo-tree.utils") + +local M = {} + +M.normalize_map_key = function(key) + if key == nil then + return nil + end + if key:match("^<[^>]+>$") then + local parts = utils.split(key, "-") + if #parts == 2 then + local mod = parts[1]:lower() + if mod == " 2 then + alpha = alpha:lower() + end + key = string.format("%s-%s", mod, alpha) + return key + else + key = key:lower() + if key == "" then + return "" + elseif key == "" then + return "" + elseif key == "" then + return "" + end + end + end + return key +end + +M.normalize_map = function(map) + local new_map = {} + for key, value in pairs(map) do + local normalized_key = M.normalize_map_key(key) + if normalized_key ~= nil then + new_map[normalized_key] = value + end + end + return new_map +end + +local tests = { + { "", "" }, + { "", "" }, + { "", "" }, + { "", "" }, + { "", "" }, + { "", "" }, + { "", "" }, + { "", "" }, +} +for _, test in ipairs(tests) do + local key = M.normalize_map_key(test[1]) + assert(key == test[2], string.format("%s != %s", key, test[2])) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/setup/netrw.lua b/bundle/neo-tree.nvim/lua/neo-tree/setup/netrw.lua new file mode 100644 index 000000000..e57756090 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/setup/netrw.lua @@ -0,0 +1,103 @@ +local utils = require("neo-tree.utils") +local log = require("neo-tree.log") +local manager = require("neo-tree.sources.manager") +local command = require("neo-tree.command") +local M = {} + +local get_position = function(source_name) + local nt = require("neo-tree") + local pos = utils.get_value(nt.config, source_name .. ".window.position", "left", true) + return pos +end + +M.get_hijack_netrw_behavior = function() + local nt = require("neo-tree") + local option = "filesystem.hijack_netrw_behavior" + local hijack_behavior = utils.get_value(nt.config, option, "open_default", true) + if hijack_behavior == "disabled" then + return hijack_behavior + elseif hijack_behavior == "open_default" then + return hijack_behavior + elseif hijack_behavior == "open_current" then + return hijack_behavior + else + log.error("Invalid value for " .. option .. ": " .. hijack_behavior) + return "disabled" + end +end + +M.hijack = function() + local hijack_behavior = M.get_hijack_netrw_behavior() + if hijack_behavior == "disabled" then + return false + end + + -- ensure this is a directory + local bufname = vim.api.nvim_buf_get_name(0) + local stats = vim.loop.fs_stat(bufname) + if not stats then + return false + end + if stats.type ~= "directory" then + return false + end + + -- record where we are now + local pos = get_position("filesystem") + local should_open_current = hijack_behavior == "open_current" or pos == "current" + local winid = vim.api.nvim_get_current_win() + local dir_bufnr = vim.api.nvim_get_current_buf() + + -- Now actually open the tree, with a very quick debounce because this may be + -- called multiple times in quick succession. + utils.debounce("hijack_netrw_" .. winid, function() + -- We will want to replace the "directory" buffer with either the "alternate" + -- buffer or a new blank one. + local replace_with_bufnr = vim.fn.bufnr("#") + local is_currently_neo_tree = false + if replace_with_bufnr > 0 then + if vim.api.nvim_buf_get_option(replace_with_bufnr, "filetype") == "neo-tree" then + -- don't hijack the current window if it's already a Neo-tree sidebar + local _, position = pcall(vim.api.nvim_buf_get_var, replace_with_bufnr, "neo_tree_position") + if position ~= "current" then + is_currently_neo_tree = true + else + replace_with_bufnr = -1 + end + end + end + if not should_open_current then + if replace_with_bufnr == dir_bufnr or replace_with_bufnr < 1 then + replace_with_bufnr = vim.api.nvim_create_buf(true, false) + log.trace("Created new buffer for netrw hijack", replace_with_bufnr) + end + end + if replace_with_bufnr > 0 then + log.trace("Replacing buffer in netrw hijack", replace_with_bufnr) + pcall(vim.api.nvim_win_set_buf, winid, replace_with_bufnr) + end + local remove_dir_buf = vim.schedule_wrap(function() + log.trace("Deleting buffer in netrw hijack", dir_bufnr) + pcall(vim.api.nvim_buf_delete, dir_bufnr, { force = true }) + end) + + local state + if should_open_current and not is_currently_neo_tree then + log.debug("hijack_netrw: opening current") + state = manager.get_state("filesystem", nil, winid) + state.current_position = "current" + elseif is_currently_neo_tree then + log.debug("hijack_netrw: opening in existing Neo-tree") + state = manager.get_state("filesystem") + else + log.debug("hijack_netrw: opening default") + manager.close_all_except("filesystem") + state = manager.get_state("filesystem") + end + require("neo-tree.sources.filesystem")._navigate_internal(state, bufname, nil, remove_dir_buf) + end, 10, utils.debounce_strategy.CALL_LAST_ONLY) + + return true +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/commands.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/commands.lua new file mode 100644 index 000000000..37ac287c8 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/commands.lua @@ -0,0 +1,92 @@ +--This file should contain all commands meant to be used by mappings. + +local vim = vim +local cc = require("neo-tree.sources.common.commands") +local buffers = require("neo-tree.sources.buffers") +local utils = require("neo-tree.utils") +local manager = require("neo-tree.sources.manager") + +local M = {} + +local refresh = utils.wrap(manager.refresh, "buffers") +local redraw = utils.wrap(manager.redraw, "buffers") + +M.add = function(state) + cc.add(state, refresh) +end + +M.add_directory = function(state) + cc.add_directory(state, refresh) +end + +M.buffer_delete = function(state) + local node = state.tree:get_node() + if node then + if node.type == "message" then + return + end + vim.api.nvim_buf_delete(node.extra.bufnr, { force = false, unload = false }) + refresh() + end +end + +---Marks node as copied, so that it can be pasted somewhere else. +M.copy_to_clipboard = function(state) + cc.copy_to_clipboard(state, redraw) +end + +M.copy_to_clipboard_visual = function(state, selected_nodes) + cc.copy_to_clipboard_visual(state, selected_nodes, redraw) +end + +---Marks node as cut, so that it can be pasted (moved) somewhere else. +M.cut_to_clipboard = function(state) + cc.cut_to_clipboard(state, redraw) +end + +M.cut_to_clipboard_visual = function(state, selected_nodes) + cc.cut_to_clipboard_visual(state, selected_nodes, redraw) +end + +M.copy = function(state) + cc.copy(state, redraw) +end + +M.move = function(state) + cc.move(state, redraw) +end + +M.show_debug_info = cc.show_debug_info + +---Pastes all items from the clipboard to the current directory. +M.paste_from_clipboard = function(state) + cc.paste_from_clipboard(state, refresh) +end + +M.delete = function(state) + cc.delete(state, refresh) +end + +---Navigate up one level. +M.navigate_up = function(state) + local parent_path, _ = utils.split_path(state.path) + buffers.navigate(state, parent_path) +end + +M.refresh = refresh + +M.rename = function(state) + cc.rename(state, refresh) +end + +M.set_root = function(state) + local tree = state.tree + local node = tree:get_node() + if node.type == "directory" then + buffers.navigate(state, node.id) + end +end + +cc._add_common_commands(M) + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/components.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/components.lua new file mode 100644 index 000000000..0d6d3b1a5 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/components.lua @@ -0,0 +1,48 @@ +-- This file contains the built-in components. Each componment is a function +-- that takes the following arguments: +-- config: A table containing the configuration provided by the user +-- when declaring this component in their renderer config. +-- node: A NuiNode object for the currently focused node. +-- state: The current state of the source providing the items. +-- +-- The function should return either a table, or a list of tables, each of which +-- contains the following keys: +-- text: The text to display for this item. +-- highlight: The highlight group to apply to this text. + +local highlights = require("neo-tree.ui.highlights") +local common = require("neo-tree.sources.common.components") +local utils = require("neo-tree.utils") + +local M = {} + +M.name = function(config, node, state) + local highlight = config.highlight or highlights.FILE_NAME_OPENED + local name = node.name + if node.type == "directory" then + if node:get_depth() == 1 then + highlight = highlights.ROOT_NAME + name = "OPEN BUFFERS in " .. name + else + highlight = highlights.DIRECTORY_NAME + end + elseif node.type == "terminal" then + if node:get_depth() == 1 then + highlight = highlights.ROOT_NAME + name = "TERMINALS" + else + highlight = highlights.FILE_NAME + end + elseif config.use_git_status_colors then + local git_status = state.components.git_status({}, node, state) + if git_status and git_status.highlight then + highlight = git_status.highlight + end + end + return { + text = name, + highlight = highlight, + } +end + +return vim.tbl_deep_extend("force", common, M) diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/init.lua new file mode 100644 index 000000000..19ff53b2d --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/init.lua @@ -0,0 +1,191 @@ +--This file should have all functions that are in the public api and either set +--or read the state of this source. + +local vim = vim +local utils = require("neo-tree.utils") +local renderer = require("neo-tree.ui.renderer") +local items = require("neo-tree.sources.buffers.lib.items") +local events = require("neo-tree.events") +local manager = require("neo-tree.sources.manager") +local git = require("neo-tree.git") + +local M = { + name = "buffers", + display_name = "  Buffers " +} + +local wrap = function(func) + return utils.wrap(func, M.name) +end + +local get_state = function() + return manager.get_state(M.name) +end + +local follow_internal = function() + if vim.bo.filetype == "neo-tree" or vim.bo.filetype == "neo-tree-popup" then + return + end + local bufnr = vim.api.nvim_get_current_buf() + local path_to_reveal = manager.get_path_to_reveal(true) or tostring(bufnr) + + local state = get_state() + if state.current_position == "float" then + return false + end + if not state.path then + return false + end + local window_exists = renderer.window_exists(state) + if window_exists then + local node = state.tree and state.tree:get_node() + if node then + if node:get_id() == path_to_reveal then + -- already focused + return false + end + end + renderer.focus_node(state, path_to_reveal, true) + end +end + +M.follow = function() + if vim.fn.bufname(0) == "COMMIT_EDITMSG" then + return false + end + utils.debounce("neo-tree-buffer-follow", function() + return follow_internal() + end, 100, utils.debounce_strategy.CALL_LAST_ONLY) +end + +local buffers_changed_internal = function() + for _, tabid in ipairs(vim.api.nvim_list_tabpages()) do + local state = manager.get_state(M.name, tabid) + if state.path and renderer.window_exists(state) then + items.get_opened_buffers(state) + if state.follow_current_file then + follow_internal() + end + end + end +end + +---Calld by autocmd when any buffer is open, closed, renamed, etc. +M.buffers_changed = function() + utils.debounce( + "buffers_changed", + buffers_changed_internal, + 100, + utils.debounce_strategy.CALL_LAST_ONLY + ) +end + +---Navigate to the given path. +---@param path string Path to navigate to. If empty, will navigate to the cwd. +M.navigate = function(state, path, path_to_reveal) + state.dirty = false + local path_changed = false + if path == nil then + path = vim.fn.getcwd() + end + if path ~= state.path then + state.path = path + path_changed = true + end + if path_to_reveal then + renderer.position.set(state, path_to_reveal) + end + + items.get_opened_buffers(state) + + if path_changed and state.bind_to_cwd then + vim.api.nvim_command("tcd " .. path) + end +end + +---Configures the plugin, should be called before the plugin is used. +---@param config table Configuration table containing any keys that the user +--wants to change from the defaults. May be empty to accept default values. +M.setup = function(config, global_config) + --Configure events for before_render + if config.before_render then + --convert to new event system + manager.subscribe(M.name, { + event = events.BEFORE_RENDER, + handler = function(state) + local this_state = get_state() + if state == this_state then + config.before_render(this_state) + end + end, + }) + elseif global_config.enable_git_status then + manager.subscribe(M.name, { + event = events.BEFORE_RENDER, + handler = function(state) + local this_state = get_state() + if state == this_state then + state.git_status_lookup = git.status(state.git_base) + end + end, + }) + manager.subscribe(M.name, { + event = events.GIT_EVENT, + handler = M.buffers_changed, + }) + end + + local refresh_events = { + events.VIM_BUFFER_ADDED, + events.VIM_BUFFER_DELETED, + } + if global_config.enable_refresh_on_write then + table.insert(refresh_events, events.VIM_BUFFER_CHANGED) + end + for _, e in ipairs(refresh_events) do + manager.subscribe(M.name, { + event = e, + handler = function(args) + if args.afile == "" or utils.is_real_file(args.afile) then + M.buffers_changed() + end + end, + }) + end + + if config.bind_to_cwd then + manager.subscribe(M.name, { + event = events.VIM_DIR_CHANGED, + handler = wrap(manager.dir_changed), + }) + end + + if global_config.enable_diagnostics then + manager.subscribe(M.name, { + event = events.VIM_DIAGNOSTIC_CHANGED, + handler = wrap(manager.diagnostics_changed), + }) + end + + --Configure event handlers for modified files + if global_config.enable_modified_markers then + manager.subscribe(M.name, { + event = events.VIM_BUFFER_MODIFIED_SET, + handler = wrap(manager.opened_buffers_changed), + }) + end + + -- Configure event handler for follow_current_file option + if config.follow_current_file then + manager.subscribe(M.name, { + event = events.VIM_BUFFER_ENTER, + handler = M.follow, + }) + manager.subscribe(M.name, { + event = events.VIM_TERMINAL_ENTER, + handler = M.follow, + }) + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/lib/items.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/lib/items.lua new file mode 100644 index 000000000..64516e227 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/buffers/lib/items.lua @@ -0,0 +1,108 @@ +local vim = vim +local renderer = require("neo-tree.ui.renderer") +local utils = require("neo-tree.utils") +local file_items = require("neo-tree.sources.common.file-items") +local log = require("neo-tree.log") + +local M = {} + +---Get a table of all open buffers, along with all parent paths of those buffers. +---The paths are the keys of the table, and all the values are 'true'. +M.get_opened_buffers = function(state) + if state.loading then + return + end + state.loading = true + local context = file_items.create_context() + context.state = state + -- Create root folder + local root = file_items.create_item(context, state.path, "directory") + root.name = vim.fn.fnamemodify(root.path, ":~") + root.loaded = true + root.search_pattern = state.search_pattern + context.folders[root.path] = root + local terminals = {} + + local function add_buffer(bufnr, path) + local is_loaded = vim.api.nvim_buf_is_loaded(bufnr) + if is_loaded or state.show_unloaded then + local is_listed = vim.fn.buflisted(bufnr) + if is_listed == 1 then + if path == "" then + path = "[No Name]" + end + local success, item = pcall(file_items.create_item, context, path, "file", bufnr) + if success then + item.extra = { + bufnr = bufnr, + is_listed = is_listed, + } + else + log.error("Error creating item for " .. path .. ": " .. item) + end + end + end + end + + local bufs = vim.api.nvim_list_bufs() + for _, b in ipairs(bufs) do + local path = vim.api.nvim_buf_get_name(b) + if vim.startswith(path, "term://") then + local name = path:match("term://(.*)//.*") + local abs_path = vim.fn.fnamemodify(name, ":p") + local has_title, title = pcall(vim.api.nvim_buf_get_var, b, "term_title") + local item = { + name = has_title and title or name, + ext = "terminal", + path = abs_path, + id = path, + type = "terminal", + loaded = true, + extra = { + bufnr = b, + is_listed = true, + }, + } + if utils.is_subpath(state.path, abs_path) then + table.insert(terminals, item) + end + elseif path == "" then + add_buffer(b, path) + else + if #state.path > 1 then + local rootsub = path:sub(1, #state.path) + -- make sure this is within the root path + if rootsub == state.path then + add_buffer(b, path) + end + else + add_buffer(b, path) + end + end + end + + local root_folders = { root } + + if #terminals > 0 then + local terminal_root = { + name = "Terminals", + id = "Terminals", + ext = "terminal", + type = "terminal", + children = terminals, + loaded = true, + search_pattern = state.search_pattern, + } + context.folders["Terminals"] = terminal_root + root_folders[2] = terminal_root + end + state.default_expanded_nodes = {} + for id, _ in pairs(context.folders) do + table.insert(state.default_expanded_nodes, id) + end + file_items.deep_sort(root.children) + renderer.show_nodes(root_folders, state) + state.loading = false +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/common/commands.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/commands.lua new file mode 100644 index 000000000..ab948e4af --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/commands.lua @@ -0,0 +1,748 @@ +--This file should contain all commands meant to be used by mappings. + +local vim = vim +local fs_actions = require("neo-tree.sources.filesystem.lib.fs_actions") +local utils = require("neo-tree.utils") +local renderer = require("neo-tree.ui.renderer") +local events = require("neo-tree.events") +local inputs = require("neo-tree.ui.inputs") +local popups = require("neo-tree.ui.popups") +local log = require("neo-tree.log") +local help = require("neo-tree.sources.common.help") +local Preview = require("neo-tree.sources.common.preview") + +---Gets the node parent folder +---@param state table to look for nodes +---@return table? node +local function get_folder_node(state) + local tree = state.tree + local node = tree:get_node() + local last_id = node:get_id() + + while node do + local insert_as_local = state.config.insert_as + local insert_as_global = require("neo-tree").config.window.insert_as + local use_parent + if insert_as_local then + use_parent = insert_as_local == "sibling" + else + use_parent = insert_as_global == "sibling" + end + + local is_open_dir = node.type == "directory" and (node:is_expanded() or node.empty_expanded) + if use_parent and not is_open_dir then + return tree:get_node(node:get_parent_id()) + end + + if node.type == "directory" then + return node + end + + local parent_id = node:get_parent_id() + if not parent_id or parent_id == last_id then + return node + else + last_id = parent_id + node = tree:get_node(parent_id) + end + end +end + +---The using_root_directory is used to decide what part of the filename to show +-- the user when asking for a new filename to e.g. create, copy to or move to. +---@param state table The state of the source +---@return string The root path from which the relative source path should be taken +local function get_using_root_directory(state) + -- default to showing only the basename of the path + local using_root_directory = get_folder_node(state):get_id() + local show_path = state.config.show_path + if show_path == "absolute" then + using_root_directory = "" + elseif show_path == "relative" then + using_root_directory = state.path + elseif show_path ~= nil and show_path ~= "none" then + log.warn( + 'A neo-tree mapping was setup with a config.show_path option with invalid value: "' + .. show_path + .. '", falling back to its default: nil/"none"' + ) + end + return using_root_directory +end + +local M = {} + +---Adds all missing common commands to the given module +---@param to_source_command_module table The commands module for a source +---@param pattern string? A pattern specifying which commands to add, nil to add all +M._add_common_commands = function(to_source_command_module, pattern) + for name, func in pairs(M) do + if + type(name) == "string" + and not to_source_command_module[name] + and (not pattern or name:find(pattern)) + and not name:find("^_") + then + to_source_command_module[name] = func + end + end +end + +---Add a new file or dir at the current node +---@param state table The state of the source +---@param callback function The callback to call when the command is done. Called with the parent node as the argument. +M.add = function(state, callback) + local node = get_folder_node(state) + local in_directory = node:get_id() + local using_root_directory = get_using_root_directory(state) + fs_actions.create_node(in_directory, callback, using_root_directory) +end + +---Add a new file or dir at the current node +---@param state table The state of the source +---@param callback function The callback to call when the command is done. Called with the parent node as the argument. +M.add_directory = function(state, callback) + local node = get_folder_node(state) + local in_directory = node:get_id() + local using_root_directory = get_using_root_directory(state) + fs_actions.create_directory(in_directory, callback, using_root_directory) +end + +M.expand_all_nodes = function(state, toggle_directory) + if toggle_directory == nil then + toggle_directory = function(_, node) + node:expand() + end + end + --state.explicitly_opened_directories = state.explicitly_opened_directories or {} + + local expand_node + expand_node = function(node) + local id = node:get_id() + if node.type == "directory" and not node:is_expanded() then + toggle_directory(state, node) + node = state.tree:get_node(id) + end + local children = state.tree:get_nodes(id) + if children then + for _, child in ipairs(children) do + if child.type == "directory" then + expand_node(child) + end + end + end + end + + for _, node in ipairs(state.tree:get_nodes()) do + expand_node(node) + end + renderer.redraw(state) +end + +M.close_node = function(state, callback) + local tree = state.tree + local node = tree:get_node() + local parent_node = tree:get_node(node:get_parent_id()) + local target_node + + if node:has_children() and node:is_expanded() then + target_node = node + else + target_node = parent_node + end + + local root = tree:get_nodes()[1] + local is_root = target_node:get_id() == root:get_id() + + if target_node and target_node:has_children() and not is_root then + target_node:collapse() + renderer.redraw(state) + renderer.focus_node(state, target_node:get_id()) + end +end + +M.close_all_subnodes = function(state) + local tree = state.tree + local node = tree:get_node() + local parent_node = tree:get_node(node:get_parent_id()) + local target_node + + if node:has_children() and node:is_expanded() then + target_node = node + else + target_node = parent_node + end + + renderer.collapse_all_nodes(tree, target_node:get_id()) + renderer.redraw(state) + renderer.focus_node(state, target_node:get_id()) +end + +M.close_all_nodes = function(state) + renderer.collapse_all_nodes(state.tree) + renderer.redraw(state) +end + +M.close_window = function(state) + renderer.close(state) +end + +M.toggle_auto_expand_width = function(state) + if state.window.position == "float" then + return + end + state.window.auto_expand_width = state.window.auto_expand_width == false + local width = utils.resolve_width(state.window.width) + if not state.window.auto_expand_width then + if (state.window.last_user_width or width) >= vim.api.nvim_win_get_width(0) then + state.window.last_user_width = width + end + vim.api.nvim_win_set_width(0, state.window.last_user_width) + state.win_width = state.window.last_user_width + state.longest_width_exact = 0 + log.trace(string.format("Collapse auto_expand_width.")) + end + renderer.redraw(state) +end + +local copy_node_to_clipboard = function(state, node) + state.clipboard = state.clipboard or {} + local existing = state.clipboard[node.id] + if existing and existing.action == "copy" then + state.clipboard[node.id] = nil + else + state.clipboard[node.id] = { action = "copy", node = node } + log.info("Copied " .. node.name .. " to clipboard") + end +end + +---Marks node as copied, so that it can be pasted somewhere else. +M.copy_to_clipboard = function(state, callback) + local node = state.tree:get_node() + if node.type == "message" then + return + end + copy_node_to_clipboard(state, node) + if callback then + callback() + end +end + +M.copy_to_clipboard_visual = function(state, selected_nodes, callback) + for _, node in ipairs(selected_nodes) do + if node.type ~= "message" then + copy_node_to_clipboard(state, node) + end + end + if callback then + callback() + end +end + +local cut_node_to_clipboard = function(state, node) + state.clipboard = state.clipboard or {} + local existing = state.clipboard[node.id] + if existing and existing.action == "cut" then + state.clipboard[node.id] = nil + else + state.clipboard[node.id] = { action = "cut", node = node } + log.info("Cut " .. node.name .. " to clipboard") + end +end + +---Marks node as cut, so that it can be pasted (moved) somewhere else. +M.cut_to_clipboard = function(state, callback) + local node = state.tree:get_node() + cut_node_to_clipboard(state, node) + if callback then + callback() + end +end + +M.cut_to_clipboard_visual = function(state, selected_nodes, callback) + for _, node in ipairs(selected_nodes) do + if node.type ~= "message" then + cut_node_to_clipboard(state, node) + end + end + if callback then + callback() + end +end + +-------------------------------------------------------------------------------- +-- Git commands +-------------------------------------------------------------------------------- + +M.git_add_file = function(state) + local node = state.tree:get_node() + if node.type == "message" then + return + end + local path = node:get_id() + local cmd = { "git", "add", path } + vim.fn.system(cmd) + events.fire_event(events.GIT_EVENT) +end + +M.git_add_all = function(state) + local cmd = { "git", "add", "-A" } + vim.fn.system(cmd) + events.fire_event(events.GIT_EVENT) +end + +M.git_commit = function(state, and_push) + local width = vim.fn.winwidth(0) - 2 + local row = vim.api.nvim_win_get_height(0) - 3 + local popup_options = { + relative = "win", + position = { + row = row, + col = 0, + }, + size = width, + } + + inputs.input("Commit message: ", "", function(msg) + local cmd = { "git", "commit", "-m", msg } + local title = "git commit" + local result = vim.fn.systemlist(cmd) + if vim.v.shell_error ~= 0 or (#result > 0 and vim.startswith(result[1], "fatal:")) then + popups.alert("ERROR: git commit", result) + return + end + if and_push then + title = "git commit && git push" + cmd = { "git", "push" } + local result2 = vim.fn.systemlist(cmd) + table.insert(result, "") + for i = 1, #result2 do + table.insert(result, result2[i]) + end + end + events.fire_event(events.GIT_EVENT) + popups.alert(title, result) + end, popup_options) +end + +M.git_commit_and_push = function(state) + M.git_commit(state, true) +end + +M.git_push = function(state) + inputs.confirm("Are you sure you want to push your changes?", function(yes) + if yes then + local result = vim.fn.systemlist({ "git", "push" }) + events.fire_event(events.GIT_EVENT) + popups.alert("git push", result) + end + end) +end + +M.git_unstage_file = function(state) + local node = state.tree:get_node() + if node.type == "message" then + return + end + local path = node:get_id() + local cmd = { "git", "reset", "--", path } + vim.fn.system(cmd) + events.fire_event(events.GIT_EVENT) +end + +M.git_revert_file = function(state) + local node = state.tree:get_node() + if node.type == "message" then + return + end + local path = node:get_id() + local cmd = { "git", "checkout", "HEAD", "--", path } + local msg = string.format("Are you sure you want to revert %s?", node.name) + inputs.confirm(msg, function(yes) + if yes then + vim.fn.system(cmd) + events.fire_event(events.GIT_EVENT) + end + end) +end + +-------------------------------------------------------------------------------- +-- END Git commands +-------------------------------------------------------------------------------- + +M.next_source = function(state) + local sources = require("neo-tree").config.sources + local sources = require("neo-tree").config.source_selector.sources + local next_source = sources[1] + for i, source_info in ipairs(sources) do + if source_info.source == state.name then + next_source = sources[i + 1] + if not next_source then + next_source = sources[1] + end + break + end + end + + require("neo-tree.command").execute({ + source = next_source.source, + position = state.current_position, + action = "focus", + }) +end + +M.prev_source = function(state) + local sources = require("neo-tree").config.sources + local sources = require("neo-tree").config.source_selector.sources + local next_source = sources[#sources] + for i, source_info in ipairs(sources) do + if source_info.source == state.name then + next_source = sources[i - 1] + if not next_source then + next_source = sources[#sources] + end + break + end + end + + require("neo-tree.command").execute({ + source = next_source.source, + position = state.current_position, + action = "focus", + }) +end + +M.show_debug_info = function(state) + print(vim.inspect(state)) +end + +---Pastes all items from the clipboard to the current directory. +---@param state table The state of the source +---@param callback function The callback to call when the command is done. Called with the parent node as the argument. +M.paste_from_clipboard = function(state, callback) + if state.clipboard then + local folder = get_folder_node(state):get_id() + -- Convert to list so to make it easier to pop items from the stack. + local clipboard_list = {} + for _, item in pairs(state.clipboard) do + table.insert(clipboard_list, item) + end + state.clipboard = nil + local handle_next_paste, paste_complete + + paste_complete = function(source, destination) + if callback then + local insert_as = require("neo-tree").config.window.insert_as + -- open the folder so the user can see the new files + local node = insert_as == "sibling" and state.tree:get_node() or state.tree:get_node(folder) + if not node then + log.warn("Could not find node for " .. folder) + end + callback(node, destination) + end + local next_item = table.remove(clipboard_list) + if next_item then + handle_next_paste(next_item) + end + end + + handle_next_paste = function(item) + if item.action == "copy" then + fs_actions.copy_node( + item.node.path, + folder .. utils.path_separator .. item.node.name, + paste_complete + ) + elseif item.action == "cut" then + fs_actions.move_node( + item.node.path, + folder .. utils.path_separator .. item.node.name, + paste_complete + ) + end + end + + local next_item = table.remove(clipboard_list) + if next_item then + handle_next_paste(next_item) + end + end +end + +---Copies a node to a new location, using typed input. +---@param state table The state of the source +---@param callback function The callback to call when the command is done. Called with the parent node as the argument. +M.copy = function(state, callback) + local node = state.tree:get_node() + if node.type == "message" then + return + end + local using_root_directory = get_using_root_directory(state) + fs_actions.copy_node(node.path, nil, callback, using_root_directory) +end + +---Moves a node to a new location, using typed input. +---@param state table The state of the source +---@param callback function The callback to call when the command is done. Called with the parent node as the argument. +M.move = function(state, callback) + local node = state.tree:get_node() + if node.type == "message" then + return + end + local using_root_directory = get_using_root_directory(state) + fs_actions.move_node(node.path, nil, callback, using_root_directory) +end + +M.delete = function(state, callback) + local tree = state.tree + local node = tree:get_node() + if node.type == "file" or node.type == "directory" then + fs_actions.delete_node(node.path, callback) + else + log.warn("The `delete` command can only be used on files and directories") + end +end + +M.delete_visual = function(state, selected_nodes, callback) + local paths_to_delete = {} + for _, node_to_delete in pairs(selected_nodes) do + if node_to_delete.type == "file" or node_to_delete.type == "directory" then + table.insert(paths_to_delete, node_to_delete.path) + end + end + fs_actions.delete_nodes(paths_to_delete, callback) +end + +M.preview = function(state) + Preview.show(state) +end + +M.revert_preview = function() + Preview.hide() +end +-- +-- Multi-purpose function to back out of whatever we are in +M.cancel = function(state) + if Preview.is_active() then + Preview.hide() + else + if state.current_position == "float" then + renderer.close_all_floating_windows() + end + end +end + +M.toggle_preview = function(state) + Preview.toggle(state) +end + +M.focus_preview = function() + Preview.focus() +end + +---Open file or directory +---@param state table The state of the source +---@param open_cmd string The vim command to use to open the file +---@param toggle_directory function The function to call to toggle a directory +---open/closed +local open_with_cmd = function(state, open_cmd, toggle_directory, open_file) + local tree = state.tree + local success, node = pcall(tree.get_node, tree) + if node.type == "message" then + return + end + if not (success and node) then + log.debug("Could not get node.") + return + end + + local function open() + M.revert_preview() + local path = node.path or node:get_id() + local bufnr = node.extra and node.extra.bufnr + if node.type == "terminal" then + path = node:get_id() + end + if type(open_file) == "function" then + open_file(state, path, open_cmd, bufnr) + else + utils.open_file(state, path, open_cmd, bufnr) + end + local extra = node.extra or {} + local pos = extra.position or extra.end_position + if pos ~= nil then + vim.api.nvim_win_set_cursor(0, { (pos[1] or 0) + 1, pos[2] or 0 }) + vim.api.nvim_win_call(0, function() + vim.cmd("normal! zvzz") -- expand folds and center cursor + end) + end + end + + if utils.is_expandable(node) then + if toggle_directory and node.type == "directory" then + toggle_directory(node) + elseif node:has_children() then + if node:is_expanded() and node.type ~= "directory" then + return open() + end + + local updated = false + if node:is_expanded() then + updated = node:collapse() + else + updated = node:expand() + end + if updated then + renderer.redraw(state) + end + end + else + open() + end +end + +---Open file or directory in the closest window +---@param state table The state of the source +---@param toggle_directory function The function to call to toggle a directory +---open/closed +M.open = function(state, toggle_directory) + open_with_cmd(state, "e", toggle_directory) +end + +---Open file or directory in a split of the closest window +---@param state table The state of the source +---@param toggle_directory function The function to call to toggle a directory +---open/closed +M.open_split = function(state, toggle_directory) + open_with_cmd(state, "split", toggle_directory) +end + +---Open file or directory in a vertical split of the closest window +---@param state table The state of the source +---@param toggle_directory function The function to call to toggle a directory +---open/closed +M.open_vsplit = function(state, toggle_directory) + open_with_cmd(state, "vsplit", toggle_directory) +end + +---Open file or directory in a new tab +---@param state table The state of the source +---@param toggle_directory function The function to call to toggle a directory +---open/closed +M.open_tabnew = function(state, toggle_directory) + open_with_cmd(state, "tabnew", toggle_directory) +end + +---Open file or directory or focus it if a buffer already exists with it +---@param state table The state of the source +---@param toggle_directory function The function to call to toggle a directory +---open/closed +M.open_drop = function(state, toggle_directory) + open_with_cmd(state, "drop", toggle_directory) +end + +---Open file or directory in new tab or focus it if a buffer already exists with it +---@param state table The state of the source +---@param toggle_directory function The function to call to toggle a directory +---open/closed +M.open_tab_drop = function(state, toggle_directory) + open_with_cmd(state, "tab drop", toggle_directory) +end + +M.rename = function(state, callback) + local tree = state.tree + local node = tree:get_node() + if node.type == "message" then + return + end + fs_actions.rename_node(node.path, callback) +end + +---Expands or collapses the current node. +M.toggle_node = function(state, toggle_directory) + local tree = state.tree + local node = tree:get_node() + if not utils.is_expandable(node) then + return + end + if node.type == "directory" and toggle_directory then + toggle_directory(node) + elseif node:has_children() then + local updated = false + if node:is_expanded() then + updated = node:collapse() + else + updated = node:expand() + end + if updated then + renderer.redraw(state) + end + end +end + +---Expands or collapses the current node. +M.toggle_directory = function(state, toggle_directory) + local tree = state.tree + local node = tree:get_node() + if node.type ~= "directory" then + return + end + M.toggle_node(state, toggle_directory) +end + +---Marks potential windows with letters and will open the give node in the picked window. +---@param state table The state of the source +---@param path string The path to open +---@param cmd string Command that is used to perform action on picked window +local use_window_picker = function(state, path, cmd) + local success, picker = pcall(require, "window-picker") + if not success then + print( + "You'll need to install window-picker to use this command: https://github.com/s1n7ax/nvim-window-picker" + ) + return + end + local events = require("neo-tree.events") + local event_result = events.fire_event(events.FILE_OPEN_REQUESTED, { + state = state, + path = path, + open_cmd = cmd, + }) or {} + if event_result.handled then + events.fire_event(events.FILE_OPENED, path) + return + end + local picked_window_id = picker.pick_window() + if picked_window_id then + vim.api.nvim_set_current_win(picked_window_id) + local result, err = pcall(vim.cmd, cmd .. " " .. vim.fn.fnameescape(path)) + if result or err == "Vim(edit):E325: ATTENTION" then + -- fixes #321 + vim.api.nvim_buf_set_option(0, "buflisted", true) + events.fire_event(events.FILE_OPENED, path) + else + log.error("Error opening file:", err) + end + end +end + +---Marks potential windows with letters and will open the give node in the picked window. +M.open_with_window_picker = function(state, toggle_directory) + open_with_cmd(state, "edit", toggle_directory, use_window_picker) +end + +---Marks potential windows with letters and will open the give node in a split next to the picked window. +M.split_with_window_picker = function(state, toggle_directory) + open_with_cmd(state, "split", toggle_directory, use_window_picker) +end + +---Marks potential windows with letters and will open the give node in a vertical split next to the picked window. +M.vsplit_with_window_picker = function(state, toggle_directory) + open_with_cmd(state, "vsplit", toggle_directory, use_window_picker) +end + +M.show_help = function(state) + help.show(state) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/common/components.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/components.lua new file mode 100644 index 000000000..b634ff2da --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/components.lua @@ -0,0 +1,440 @@ +-- This file contains the built-in components. Each componment is a function +-- that takes the following arguments: +-- config: A table containing the configuration provided by the user +-- when declaring this component in their renderer config. +-- node: A NuiNode object for the currently focused node. +-- state: The current state of the source providing the items. +-- +-- The function should return either a table, or a list of tables, each of which +-- contains the following keys: +-- text: The text to display for this item. +-- highlight: The highlight group to apply to this text. + +local highlights = require("neo-tree.ui.highlights") +local utils = require("neo-tree.utils") +local file_nesting = require("neo-tree.sources.common.file-nesting") +local container = require("neo-tree.sources.common.container") +local log = require("neo-tree.log") + +local M = {} + +local make_two_char = function(symbol) + if vim.fn.strchars(symbol) == 1 then + return symbol .. " " + else + return symbol + end +end +-- only works in the buffers component, but it's here so we don't have to defined +-- multple renderers. +M.bufnr = function(config, node, state) + local highlight = config.highlight or highlights.BUFFER_NUMBER + local bufnr = node.extra and node.extra.bufnr + if not bufnr then + return {} + end + return { + text = string.format("#%s", bufnr), + highlight = highlight, + } +end + +M.clipboard = function(config, node, state) + local clipboard = state.clipboard or {} + local clipboard_state = clipboard[node:get_id()] + if not clipboard_state then + return {} + end + return { + text = " (" .. clipboard_state.action .. ")", + highlight = config.highlight or highlights.DIM_TEXT, + } +end + +M.container = container.render + +M.current_filter = function(config, node, state) + local filter = node.search_pattern or "" + if filter == "" then + return {} + end + return { + { + text = "Find", + highlight = highlights.DIM_TEXT, + }, + { + text = string.format('"%s"', filter), + highlight = config.highlight or highlights.FILTER_TERM, + }, + { + text = "in", + highlight = highlights.DIM_TEXT, + }, + } +end + +M.diagnostics = function(config, node, state) + local diag = state.diagnostics_lookup or {} + local diag_state = diag[node:get_id()] + if config.hide_when_expanded and node.type == "directory" and node:is_expanded() then + return {} + end + if not diag_state then + return {} + end + if config.errors_only and diag_state.severity_number > 1 then + return {} + end + local severity = diag_state.severity_string + local defined = vim.fn.sign_getdefined("DiagnosticSign" .. severity) + if not defined then + -- backwards compatibility... + local old_severity = severity + if severity == "Warning" then + old_severity = "Warn" + elseif severity == "Information" then + old_severity = "Info" + end + defined = vim.fn.sign_getdefined("LspDiagnosticsSign" .. old_severity) + end + defined = defined and defined[1] + + -- check for overrides in the component config + local severity_lower = severity:lower() + if config.symbols and config.symbols[severity_lower] then + defined = defined or { texthl = "Diagnostic" .. severity } + defined.text = config.symbols[severity_lower] + end + if config.highlights and config.highlights[severity_lower] then + defined = defined or { text = severity:sub(1, 1) } + defined.texthl = config.highlights[severity_lower] + end + + if defined and defined.text and defined.texthl then + return { + text = make_two_char(defined.text), + highlight = defined.texthl, + } + else + return { + text = severity:sub(1, 1), + highlight = "Diagnostic" .. severity, + } + end +end + +M.git_status = function(config, node, state) + local git_status_lookup = state.git_status_lookup + if config.hide_when_expanded and node.type == "directory" and node:is_expanded() then + return {} + end + if not git_status_lookup then + return {} + end + local git_status = git_status_lookup[node.path] + if not git_status then + if node.filtered_by and node.filtered_by.gitignored then + git_status = "!!" + else + return {} + end + end + + local symbols = config.symbols or {} + local change_symbol + local change_highlt = highlights.FILE_NAME + local status_symbol = symbols.staged + local status_highlt = highlights.GIT_STAGED + if node.type == "directory" and git_status:len() == 1 then + status_symbol = nil + end + + if git_status:sub(1, 1) == " " then + status_symbol = symbols.unstaged + status_highlt = highlights.GIT_UNSTAGED + end + + if git_status:match("?$") then + status_symbol = nil + status_highlt = highlights.GIT_UNTRACKED + change_symbol = symbols.untracked + change_highlt = highlights.GIT_UNTRACKED + -- all variations of merge conflicts + elseif git_status == "DD" then + status_symbol = symbols.conflict + status_highlt = highlights.GIT_CONFLICT + change_symbol = symbols.deleted + change_highlt = highlights.GIT_CONFLICT + elseif git_status == "UU" then + status_symbol = symbols.conflict + status_highlt = highlights.GIT_CONFLICT + change_symbol = symbols.modified + change_highlt = highlights.GIT_CONFLICT + elseif git_status == "AA" then + status_symbol = symbols.conflict + status_highlt = highlights.GIT_CONFLICT + change_symbol = symbols.added + change_highlt = highlights.GIT_CONFLICT + elseif git_status:match("U") then + status_symbol = symbols.conflict + status_highlt = highlights.GIT_CONFLICT + if git_status:match("A") then + change_symbol = symbols.added + elseif git_status:match("D") then + change_symbol = symbols.deleted + end + change_highlt = highlights.GIT_CONFLICT + -- end merge conflict section + elseif git_status:match("M") then + change_symbol = symbols.modified + change_highlt = highlights.GIT_MODIFIED + elseif git_status:match("R") then + change_symbol = symbols.renamed + change_highlt = highlights.GIT_RENAMED + elseif git_status:match("[ACT]") then + change_symbol = symbols.added + change_highlt = highlights.GIT_ADDED + elseif git_status:match("!") then + status_symbol = nil + change_symbol = symbols.ignored + change_highlt = highlights.GIT_IGNORED + elseif git_status:match("D") then + change_symbol = symbols.deleted + change_highlt = highlights.GIT_DELETED + end + + if change_symbol or status_symbol then + local components = {} + if type(change_symbol) == "string" and #change_symbol > 0 then + table.insert(components, { + text = make_two_char(change_symbol), + highlight = change_highlt, + }) + end + if type(status_symbol) == "string" and #status_symbol > 0 then + table.insert(components, { + text = make_two_char(status_symbol), + highlight = status_highlt, + }) + end + return components + else + return { + text = "[" .. git_status .. "]", + highlight = config.highlight or change_highlt, + } + end +end + +M.filtered_by = function(config, node, state) + local result = {} + if type(node.filtered_by) == "table" then + local fby = node.filtered_by + if fby.name then + result = { + text = "(hide by name)", + highlight = highlights.HIDDEN_BY_NAME, + } + elseif fby.pattern then + result = { + text = "(hide by pattern)", + highlight = highlights.HIDDEN_BY_NAME, + } + elseif fby.gitignored then + result = { + text = "(gitignored)", + highlight = highlights.GIT_IGNORED, + } + elseif fby.dotfiles then + result = { + text = "(dotfile)", + highlight = highlights.DOTFILE, + } + elseif fby.hidden then + result = { + text = "(hidden)", + highlight = highlights.WINDOWS_HIDDEN, + } + end + fby = nil + end + return result +end + +M.icon = function(config, node, state) + local icon = config.default or " " + local highlight = config.highlight or highlights.FILE_ICON + if node.type == "directory" then + highlight = highlights.DIRECTORY_ICON + if node.loaded and not node:has_children() then + icon = not node.empty_expanded and config.folder_empty or config.folder_empty_open + elseif node:is_expanded() then + icon = config.folder_open or "-" + else + icon = config.folder_closed or "+" + end + elseif node.type == "file" or node.type == "terminal" then + local success, web_devicons = pcall(require, "nvim-web-devicons") + if success then + local devicon, hl = web_devicons.get_icon(node.name, node.ext) + icon = devicon or icon + highlight = hl or highlight + end + end + + local filtered_by = M.filtered_by(config, node, state) + + return { + text = icon .. " ", + highlight = filtered_by.highlight or highlight, + } +end + +M.modified = function(config, node, state) + local opened_buffers = state.opened_buffers or {} + local buf_info = opened_buffers[node.path] + + if buf_info and buf_info.modified then + return { + text = (make_two_char(config.symbol) or "[+]"), + highlight = config.highlight or highlights.MODIFIED, + } + else + return {} + end +end + +M.name = function(config, node, state) + local highlight = config.highlight or highlights.FILE_NAME + local text = node.name + if node.type == "directory" then + highlight = highlights.DIRECTORY_NAME + if config.trailing_slash and text ~= "/" then + text = text .. "/" + end + end + + if node:get_depth() == 1 and node.type ~= "message" then + highlight = highlights.ROOT_NAME + else + local filtered_by = M.filtered_by(config, node, state) + highlight = filtered_by.highlight or highlight + if config.use_git_status_colors then + local git_status = state.components.git_status({}, node, state) + if git_status and git_status.highlight then + highlight = git_status.highlight + end + end + end + + local hl_opened = config.highlight_opened_files + if hl_opened then + local opened_buffers = state.opened_buffers or {} + if + (hl_opened == "all" and opened_buffers[node.path]) + or (opened_buffers[node.path] and opened_buffers[node.path].loaded) + then + highlight = highlights.FILE_NAME_OPENED + end + end + + if type(config.right_padding) == "number" then + if config.right_padding > 0 then + text = text .. string.rep(" ", config.right_padding) + end + else + text = text + end + + return { + text = text, + highlight = highlight, + } +end + +M.indent = function(config, node, state) + if not state.skip_marker_at_level then + state.skip_marker_at_level = {} + end + + local strlen = vim.fn.strdisplaywidth + local skip_marker = state.skip_marker_at_level + local indent_size = config.indent_size or 2 + local padding = config.padding or 0 + local level = node.level + local with_markers = config.with_markers + local with_expanders = config.with_expanders == nil and file_nesting.is_enabled() + or config.with_expanders + local marker_highlight = config.highlight or highlights.INDENT_MARKER + local expander_highlight = config.expander_highlight or config.highlight or highlights.EXPANDER + + local function get_expander() + if with_expanders and utils.is_expandable(node) then + return node:is_expanded() and (config.expander_expanded or "") + or (config.expander_collapsed or "") + end + end + + if indent_size == 0 or level < 2 or not with_markers then + local len = indent_size * level + padding + local expander = get_expander() + if level == 0 or not expander then + return { + text = string.rep(" ", len), + } + end + return { + text = string.rep(" ", len - strlen(expander) - 1) .. expander .. " ", + highlight = expander_highlight, + } + end + + local indent_marker = config.indent_marker or "│" + local last_indent_marker = config.last_indent_marker or "└" + + skip_marker[level] = node.is_last_child + local indent = {} + if padding > 0 then + table.insert(indent, { text = string.rep(" ", padding) }) + end + + for i = 1, level do + local char = "" + local spaces_count = indent_size + local highlight = nil + + if i > 1 and not skip_marker[i] or i == level then + spaces_count = spaces_count - 1 + char = indent_marker + highlight = marker_highlight + if i == level then + local expander = get_expander() + if expander then + char = expander + highlight = expander_highlight + elseif node.is_last_child then + char = last_indent_marker + spaces_count = spaces_count - (vim.api.nvim_strwidth(last_indent_marker) - 1) + end + end + end + + table.insert(indent, { text = char .. string.rep(" ", spaces_count), highlight = highlight }) + end + + return indent +end + +M.symlink_target = function(config, node, state) + if node.is_link then + return { + text = string.format(" ➛ %s", node.link_to), + highlight = config.highlight or highlights.SYMBOLIC_LINK_TARGET, + } + else + return {} + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/common/container.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/container.lua new file mode 100644 index 000000000..1e49778c7 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/container.lua @@ -0,0 +1,331 @@ +local utils = require("neo-tree.utils") +local renderer = require("neo-tree.ui.renderer") +local highlights = require("neo-tree.ui.highlights") +local log = require("neo-tree.log") + +local M = {} + +local calc_rendered_width = function(rendered_item) + local width = 0 + + for _, item in ipairs(rendered_item) do + if item.text then + width = width + vim.fn.strchars(item.text) + end + end + + return width +end + +local calc_container_width = function(config, node, state, context) + local container_width = 0 + if type(config.width) == "string" then + if config.width == "fit_content" then + container_width = context.max_width + elseif config.width == "100%" then + container_width = context.available_width + elseif config.width:match("^%d+%%$") then + local percent = tonumber(config.width:sub(1, -2)) / 100 + container_width = math.floor(percent * context.available_width) + else + error("Invalid container width: " .. config.width) + end + elseif type(config.width) == "number" then + container_width = config.width + elseif type(config.width) == "function" then + container_width = config.width(node, state) + else + error("Invalid container width: " .. config.width) + end + + if config.min_width then + container_width = math.max(container_width, config.min_width) + end + if config.max_width then + container_width = math.min(container_width, config.max_width) + end + context.container_width = container_width + return container_width +end + +local render_content = function(config, node, state, context) + local add_padding = function(rendered_item, should_pad) + for _, data in ipairs(rendered_item) do + if data.text then + local padding = (should_pad and #data.text and data.text:sub(1, 1) ~= " ") and " " or "" + data.text = padding .. data.text + should_pad = data.text:sub(#data.text) ~= " " + end + end + return should_pad + end + + local max_width = 0 + local grouped_by_zindex = utils.group_by(config.content, "zindex") + + for zindex, items in pairs(grouped_by_zindex) do + local should_pad = { left = false, right = false } + local zindex_rendered = { left = {}, right = {} } + local rendered_width = 0 + + for _, item in ipairs(items) do + local rendered_item = renderer.render_component(item, node, state, context.available_width) + if rendered_item then + local align = item.align or "left" + should_pad[align] = add_padding(rendered_item, should_pad[align]) + + vim.list_extend(zindex_rendered[align], rendered_item) + rendered_width = rendered_width + calc_rendered_width(rendered_item) + end + end + + max_width = math.max(max_width, rendered_width) + grouped_by_zindex[zindex] = zindex_rendered + end + + context.max_width = max_width + context.grouped_by_zindex = grouped_by_zindex + return context +end + +---Takes a list of rendered components and truncates them to fit the container width +---@param layer table The list of rendered components. +---@param skip_count number The number of characters to skip from the begining/left. +---@param max_length number The maximum number of characters to return. +local truncate_layer_keep_left = function(layer, skip_count, max_length) + local result = {} + local taken = 0 + local skipped = 0 + for _, item in ipairs(layer) do + local remaining_to_skip = skip_count - skipped + if remaining_to_skip > 0 then + if #item.text <= remaining_to_skip then + skipped = skipped + vim.fn.strchars(item.text) + item.text = "" + else + item.text = item.text:sub(remaining_to_skip) + if #item.text + taken > max_length then + item.text = item.text:sub(1, max_length - taken) + end + table.insert(result, item) + taken = taken + #item.text + skipped = skipped + remaining_to_skip + end + elseif taken <= max_length then + if #item.text + taken > max_length then + item.text = item.text:sub(1, max_length - taken) + end + table.insert(result, item) + taken = taken + vim.fn.strchars(item.text) + end + end + return result +end + +---Takes a list of rendered components and truncates them to fit the container width +---@param layer table The list of rendered components. +---@param skip_count number The number of characters to skip from the end/right. +---@param max_length number The maximum number of characters to return. +local truncate_layer_keep_right = function(layer, skip_count, max_length) + local result = {} + local taken = 0 + local skipped = 0 + local i = #layer + while i > 0 do + local item = layer[i] + i = i - 1 + local text_length = vim.fn.strchars(item.text) + local remaining_to_skip = skip_count - skipped + if remaining_to_skip > 0 then + if text_length <= remaining_to_skip then + skipped = skipped + text_length + item.text = "" + else + item.text = vim.fn.strcharpart(item.text, 0, text_length - remaining_to_skip) + text_length = vim.fn.strchars(item.text) + if text_length + taken > max_length then + item.text = vim.fn.strcharpart(item.text, text_length - (max_length - taken)) + text_length = vim.fn.strchars(item.text) + end + table.insert(result, item) + taken = taken + text_length + skipped = skipped + remaining_to_skip + end + elseif taken <= max_length then + if text_length + taken > max_length then + item.text = vim.fn.strcharpart(item.text, text_length - (max_length - taken)) + text_length = vim.fn.strchars(item.text) + end + table.insert(result, item) + taken = taken + text_length + end + end + return result +end + +local fade_content = function(layer, fade_char_count) + local text = layer[#layer].text + if not text or #text == 0 then + return + end + local hl = layer[#layer].highlight or "Normal" + local fade = { + highlights.get_faded_highlight_group(hl, 0.68), + highlights.get_faded_highlight_group(hl, 0.6), + highlights.get_faded_highlight_group(hl, 0.35), + } + + for i = 3, 1, -1 do + if #text >= i and fade_char_count >= i then + layer[#layer].text = text:sub(1, -i - 1) + for j = i, 1, -1 do + -- force no padding for each faded character + local entry = { text = text:sub(-j, -j), highlight = fade[i - j + 1], no_padding = true } + table.insert(layer, entry) + end + break + end + end +end + +local try_fade_content = function(layer, fade_char_count) + local success, err = pcall(fade_content, layer, fade_char_count) + if not success then + log.debug("Error while trying to fade content: ", err) + end +end + +local merge_content = function(context) + -- Heres the idea: + -- * Starting backwards from the layer with the highest zindex + -- set the left and right tables to the content of the layer + -- * If a layer has more content than will fit, the left side will be truncated. + -- * If the available space is not used up, move on to the next layer + -- * With each subsequent layer, if the length of that layer is greater then the existing + -- length for that side (left or right), then clip that layer and append whatver portion is + -- not covered up to the appropriate side. + -- * Check again to see if we have used up the available width, short circuit if we have. + -- * Repeat until all layers have been merged. + -- * Join the left and right tables together and return. + -- + local remaining_width = context.container_width + local left, right = {}, {} + local left_width, right_width = 0, 0 + local wanted_width = 0 + + if context.left_padding and context.left_padding > 0 then + table.insert(left, { text = string.rep(" ", context.left_padding) }) + remaining_width = remaining_width - context.left_padding + left_width = left_width + context.left_padding + wanted_width = wanted_width + context.left_padding + end + + if context.right_padding and context.right_padding > 0 then + remaining_width = remaining_width - context.right_padding + wanted_width = wanted_width + context.right_padding + end + + local keys = utils.get_keys(context.grouped_by_zindex, true) + if type(keys) ~= "table" then + return {} + end + local i = #keys + while i > 0 do + local key = keys[i] + local layer = context.grouped_by_zindex[key] + i = i - 1 + + if utils.truthy(layer.right) then + local width = calc_rendered_width(layer.right) + wanted_width = wanted_width + width + if remaining_width > 0 then + context.has_right_content = true + if width > remaining_width then + local truncated = truncate_layer_keep_right(layer.right, right_width, remaining_width) + vim.list_extend(right, truncated) + remaining_width = 0 + else + remaining_width = remaining_width - width + vim.list_extend(right, layer.right) + right_width = right_width + width + end + end + end + + if utils.truthy(layer.left) then + local width = calc_rendered_width(layer.left) + wanted_width = wanted_width + width + if remaining_width > 0 then + if width > remaining_width then + local truncated = truncate_layer_keep_left(layer.left, left_width, remaining_width) + if context.enable_character_fade then + try_fade_content(truncated, 3) + end + vim.list_extend(left, truncated) + remaining_width = 0 + else + remaining_width = remaining_width - width + if context.enable_character_fade and not context.auto_expand_width then + local fade_chars = 3 - remaining_width + if fade_chars > 0 then + try_fade_content(layer.left, fade_chars) + end + end + vim.list_extend(left, layer.left) + left_width = left_width + width + end + end + end + + if remaining_width == 0 and not context.auto_expand_width then + i = 0 + break + end + end + + if remaining_width > 0 and #right > 0 then + table.insert(left, { text = string.rep(" ", remaining_width) }) + end + + local result = {} + vim.list_extend(result, left) + + -- we do not pad between left and right side + if #right >= 1 then + right[1].no_padding = true + end + + vim.list_extend(result, right) + context.merged_content = result + log.trace("wanted width: ", wanted_width, " actual width: ", context.container_width) + context.wanted_width = math.max(wanted_width, context.wanted_width) +end + +M.render = function(config, node, state, available_width) + local context = { + wanted_width = 0, + max_width = 0, + grouped_by_zindex = {}, + available_width = available_width, + left_padding = config.left_padding, + right_padding = config.right_padding, + enable_character_fade = config.enable_character_fade, + auto_expand_width = state.window.auto_expand_width and state.window.position ~= "float", + } + + render_content(config, node, state, context) + calc_container_width(config, node, state, context) + merge_content(context) + + if context.has_right_content then + state.has_right_content = true + end + + -- we still want padding between this container and the previous component + if #context.merged_content > 0 then + context.merged_content[1].no_padding = false + end + return context.merged_content, context.wanted_width +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/common/file-items.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/file-items.lua new file mode 100644 index 000000000..2114c29c1 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/file-items.lua @@ -0,0 +1,231 @@ +local vim = vim +local files_nesting = require("neo-tree.sources.common.file-nesting") +local utils = require("neo-tree.utils") +local log = require("neo-tree.log") +local git = require("neo-tree.git") + +local unused_to_produce_diagnostic = {} + +local function sort_items(a, b) + if a.type == b.type then + return a.path < b.path + else + return a.type < b.type + end +end + +local function sort_items_case_insensitive(a, b) + if a.type == b.type then + return a.path:lower() < b.path:lower() + else + return a.type < b.type + end +end + +local function sort_function_is_valid(func) + if func == nil then + return false + end + + local a = { type = "dir", path = "foo" } + local b = { type = "dir", path = "baz" } + + local success, result = pcall(func, a, b) + if success and type(result) == "boolean" then + return true + end + + log.error("sort function isn't valid ", result) + return false +end + +local function deep_sort(tbl, sort_func) + if sort_func == nil then + local config = require("neo-tree").config + if sort_function_is_valid(config.sort_function) then + sort_func = config.sort_function + elseif config.sort_case_insensitive then + sort_func = sort_items_case_insensitive + else + sort_func = sort_items + end + end + table.sort(tbl, sort_func) + for _, item in pairs(tbl) do + if item.type == "directory" then + deep_sort(item.children, sort_func) + end + end +end + +local create_item, set_parents + +function create_item(context, path, _type, bufnr) + local parent_path, name = utils.split_path(path) + local id = path + if path == "[No Name]" and bufnr then + parent_path = context.state.path + name = "[No Name]" + id = tostring(bufnr) + else + -- avoid creating duplicate items + if context.folders[path] or context.nesting[path] then + return context.folders[path] or context.nesting[path] + end + end + + if _type == nil then + local stat = vim.loop.fs_stat(path) + _type = stat and stat.type or "unknown" + end + local item = { + id = id, + name = name, + parent_path = parent_path, + path = path, + type = _type, + } + if item.type == "link" then + item.is_link = true + item.link_to = vim.loop.fs_realpath(path) + if item.link_to ~= nil then + item.type = vim.loop.fs_stat(item.link_to).type + end + end + if item.type == "directory" then + item.children = {} + item.loaded = false + context.folders[path] = item + if context.state.search_pattern then + table.insert(context.state.default_expanded_nodes, item.id) + end + else + item.base = item.name:match("^([-_,()%s%w%i]+)%.") + item.ext = item.name:match("%.([-_,()%s%w%i]+)$") + item.exts = item.name:match("^[-_,()%s%w%i]+%.(.*)") + + if files_nesting.can_have_nesting(item) then + item.children = {} + context.nesting[path] = item + end + end + + item.is_reveal_target = (path == context.path_to_reveal) + local state = context.state + local f = state.filtered_items + local is_not_root = not utils.is_subpath(path, context.state.path) + if f and is_not_root then + if f.never_show[name] then + item.filtered_by = item.filtered_by or {} + item.filtered_by.never_show = true + else + if utils.is_filtered_by_pattern(f.never_show_by_pattern, path, name) then + item.filtered_by = item.filtered_by or {} + item.filtered_by.never_show = true + end + end + if f.always_show[name] then + item.filtered_by = item.filtered_by or {} + item.filtered_by.always_show = true + end + if f.hide_by_name[name] then + item.filtered_by = item.filtered_by or {} + item.filtered_by.name = true + end + if utils.is_filtered_by_pattern(f.hide_by_pattern, path, name) then + item.filtered_by = item.filtered_by or {} + item.filtered_by.pattern = true + end + if f.hide_dotfiles and string.sub(name, 1, 1) == "." then + item.filtered_by = item.filtered_by or {} + item.filtered_by.dotfiles = true + end + if f.hide_hidden and utils.is_hidden(path) then + item.filtered_by = item.filtered_by or {} + item.filtered_by.hidden = true + end + -- NOTE: git_ignored logic moved to job_complete + end + + set_parents(context, item) + if context.all_items == nil then + context.all_items = {} + end + if is_not_root then + table.insert(context.all_items, item) + end + return item +end + +-- function to set (or create) parent folder +function set_parents(context, item) + -- we can get duplicate items if we navigate up with open folders + -- this is probably hacky, but it works + if context.item_exists[item.id] then + return + end + if not item.parent_path then + return + end + + local nesting_parent_path = files_nesting.get_parent(item) + local nesting_parent = context.nesting[nesting_parent_path] + + if + nesting_parent_path + and not nesting_parent + and utils.truthy(vim.loop.fs_stat(nesting_parent_path)) + then + local success + success, nesting_parent = pcall(create_item, context, nesting_parent_path) + if not success then + log.error("error, creating item for ", nesting_parent_path) + end + end + + local parent = context.folders[item.parent_path] + if not utils.truthy(item.parent_path) then + return + end + if parent == nil and nesting_parent == nil then + local success + success, parent = pcall(create_item, context, item.parent_path, "directory") + if not success then + log.error("error creating item for ", item.parent_path) + end + context.folders[parent.id] = parent + set_parents(context, parent) + end + if nesting_parent then + table.insert(nesting_parent.children, item) + item.is_nested = true + else + table.insert(parent.children, item) + end + context.item_exists[item.id] = true + + if item.filtered_by == nil and type(parent.filtered_by) == "table" then + item.filtered_by = vim.deepcopy(parent.filtered_by) + end +end + +---Create context to be used in other file-items functions. +---@param state table|nil The state of the file-items. +---@return table +local create_context = function(state) + local context = {} + -- Make the context a weak table so that it can be garbage collected + --setmetatable(context, { __mode = 'v' }) + context.state = state + context.folders = {} + context.nesting = {} + context.item_exists = {} + context.all_items = {} + return context +end + +return { + create_context = create_context, + create_item = create_item, + deep_sort = deep_sort, +} diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/common/file-nesting.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/file-nesting.lua new file mode 100644 index 000000000..d4c13ef2a --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/file-nesting.lua @@ -0,0 +1,54 @@ +local iter = require("plenary.iterators").iter +local utils = require("neo-tree.utils") +local Path = require("plenary.path") + +-- File nesting a la JetBrains (#117). +local M = {} +M.config = {} + +--- Checks if file-nesting module is enabled by config +---@return boolean +function M.is_enabled() + return next(M.config) ~= nil +end + +--- Returns `item` nesting parent path if exists +---@return string? +function M.get_parent(item) + for base_exts, nesting_exts in pairs(M.config) do + for _, exts in ipairs(nesting_exts) do + if item.exts == exts then + local parent_id = utils.path_join(item.parent_path, item.base) .. "." .. base_exts + if Path:new(parent_id):exists() then + return parent_id + end + end + end + end + + return nil +end + +--- Checks if `item` have a valid nesting lookup +---@return boolean +function M.can_have_nesting(item) + return utils.truthy(M.config[item.exts]) +end + +--- Checks if `target` should be nested into `base` +---@return boolean +function M.should_nest_file(base, target) + local ext_lookup = M.config[base.exts] + + return utils.truthy( + base.base == target.base and ext_lookup and iter(ext_lookup):find(target.exts) + ) +end + +---Setup the module with the given config +---@param config table +function M.setup(config) + M.config = config or {} +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/common/filters/filter_fzy.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/filters/filter_fzy.lua new file mode 100644 index 000000000..8f155bf08 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/filters/filter_fzy.lua @@ -0,0 +1,246 @@ +-- The lua implementation of the fzy string matching algorithm +-- credits to: https://github.com/swarn/fzy-lua +--[[ +The MIT License (MIT) + +Copyright (c) 2020 Seth Warn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +--]] +-- modified by: @pysan3 (2023) + +local SCORE_GAP_LEADING = -0.005 +local SCORE_GAP_TRAILING = -0.005 +local SCORE_GAP_INNER = -0.01 +local SCORE_MATCH_CONSECUTIVE = 1.0 +local SCORE_MATCH_SLASH = 0.9 +local SCORE_MATCH_WORD = 0.8 +local SCORE_MATCH_CAPITAL = 0.7 +local SCORE_MATCH_DOT = 0.6 +local SCORE_MAX = math.huge +local SCORE_MIN = -math.huge +local MATCH_MAX_LENGTH = 1024 + +local M = {} + +-- Return `true` if `needle` is a subsequence of `haystack`. +function M.has_match(needle, haystack, case_sensitive) + if not case_sensitive then + needle = string.lower(needle) + haystack = string.lower(haystack) + end + + local j = 1 + for i = 1, string.len(needle) do + j = string.find(haystack, needle:sub(i, i), j, true) + if not j then + return false + else + j = j + 1 + end + end + + return true +end + +local function is_lower(c) + return c:match('%l') +end + +local function is_upper(c) + return c:match('%u') +end + +local function precompute_bonus(haystack) + local match_bonus = {} + + local last_char = '/' + for i = 1, string.len(haystack) do + local this_char = haystack:sub(i, i) + if last_char == '/' or last_char == '\\' then + match_bonus[i] = SCORE_MATCH_SLASH + elseif last_char == '-' or last_char == '_' or last_char == ' ' then + match_bonus[i] = SCORE_MATCH_WORD + elseif last_char == '.' then + match_bonus[i] = SCORE_MATCH_DOT + elseif is_lower(last_char) and is_upper(this_char) then + match_bonus[i] = SCORE_MATCH_CAPITAL + else + match_bonus[i] = 0 + end + + last_char = this_char + end + + return match_bonus +end + +local function compute(needle, haystack, D, T, case_sensitive) + -- Note that the match bonuses must be computed before the arguments are + -- converted to lowercase, since there are bonuses for camelCase. + local match_bonus = precompute_bonus(haystack) + local n = string.len(needle) + local m = string.len(haystack) + + if not case_sensitive then + needle = string.lower(needle) + haystack = string.lower(haystack) + end + + -- Because lua only grants access to chars through substring extraction, + -- get all the characters from the haystack once now, to reuse below. + local haystack_chars = {} + for i = 1, m do + haystack_chars[i] = haystack:sub(i, i) + end + + for i = 1, n do + D[i] = {} + T[i] = {} + + local prev_score = SCORE_MIN + local gap_score = i == n and SCORE_GAP_TRAILING or SCORE_GAP_INNER + local needle_char = needle:sub(i, i) + + for j = 1, m do + if needle_char == haystack_chars[j] then + local score = SCORE_MIN + if i == 1 then + score = ((j - 1) * SCORE_GAP_LEADING) + match_bonus[j] + elseif j > 1 then + local a = T[i - 1][j - 1] + match_bonus[j] + local b = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE + score = math.max(a, b) + end + D[i][j] = score + prev_score = math.max(score, prev_score + gap_score) + T[i][j] = prev_score + else + D[i][j] = SCORE_MIN + prev_score = prev_score + gap_score + T[i][j] = prev_score + end + end + end +end + +-- Compute a matching score for two strings. +-- +-- Where `needle` is a subsequence of `haystack`, this returns a score +-- measuring the quality of their match. Better matches get higher scores. +-- +-- `needle` must be a subsequence of `haystack`, the result is undefined +-- otherwise. Call `has_match()` before calling `score`. +-- +-- returns `get_score_min()` where a or b are longer than `get_max_length()` +-- +-- returns `get_score_min()` when a or b are empty strings. +-- +-- returns `get_score_max()` when a and b are the same string. +-- +-- When the return value is not covered by the above rules, it is a number +-- in the range (`get_score_floor()`, `get_score_ceiling()`) +function M.score(needle, haystack, case_sensitive) + local n = string.len(needle) + local m = string.len(haystack) + + if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > MATCH_MAX_LENGTH then + return SCORE_MIN + elseif n == m then + return SCORE_MAX + else + local D = {} + local T = {} + compute(needle, haystack, D, T, case_sensitive) + return T[n][m] + end +end + +-- Find the locations where fzy matched a string. +-- +-- Returns {score, indices}, where indices is an array showing where each +-- character of the needle matches the haystack in the best match. +function M.score_and_positions(needle, haystack, case_sensitive) + local n = string.len(needle) + local m = string.len(haystack) + + if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > MATCH_MAX_LENGTH then + return SCORE_MIN, {} + elseif n == m then + local consecutive = {} + for i = 1, n do + consecutive[i] = i + end + return SCORE_MAX, consecutive + end + + local D = {} + local T = {} + compute(needle, haystack, D, T, case_sensitive) + + local positions = {} + local match_required = false + local j = m + for i = n, 1, -1 do + while j >= 1 do + if D[i][j] ~= SCORE_MIN and (match_required or D[i][j] == T[i][j]) then + match_required = (i ~= 1) and (j ~= 1) and (T[i][j] == D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE) + positions[i] = j + j = j - 1 + break + else + j = j - 1 + end + end + end + + return T[n][m], positions +end + +-- Return only the positions of a match. +function M.positions(needle, haystack, case_sensitive) + local _, positions = M.score_and_positions(needle, haystack, case_sensitive) + return positions +end + +function M.get_score_min() + return SCORE_MIN +end + +function M.get_score_max() + return SCORE_MAX +end + +function M.get_max_length() + return MATCH_MAX_LENGTH +end + +function M.get_score_floor() + return MATCH_MAX_LENGTH * SCORE_GAP_INNER +end + +function M.get_score_ceiling() + return MATCH_MAX_LENGTH * SCORE_MATCH_CONSECUTIVE +end + +function M.get_implementation_name() + return 'lua' +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/common/filters/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/filters/init.lua new file mode 100644 index 000000000..6941a2fc4 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/filters/init.lua @@ -0,0 +1,225 @@ +---A generalization of the filter functionality to directly filter the +---source tree instead of relying on pre-filtered data, which is specific +---to the filesystem source. +local vim = vim +local Input = require("nui.input") +local event = require("nui.utils.autocmd").event +local popups = require("neo-tree.ui.popups") +local renderer = require("neo-tree.ui.renderer") +local utils = require("neo-tree.utils") +local log = require("neo-tree.log") +local manager = require("neo-tree.sources.manager") +local fzy = require("neo-tree.sources.common.filters.filter_fzy") + +local M = {} + +local cmds = { + move_cursor_down = function(state, scroll_padding) + renderer.focus_node(state, nil, true, 1, scroll_padding) + end, + + move_cursor_up = function(state, scroll_padding) + renderer.focus_node(state, nil, true, -1, scroll_padding) + vim.cmd("redraw!") + end, +} + +---Reset the current filter to the empty string. +---@param state any +---@param refresh boolean? whether to refresh the source tree +---@param open_current_node boolean? whether to open the current node +local reset_filter = function(state, refresh, open_current_node) + log.trace("reset_search") + if refresh == nil then + refresh = true + end + + -- Cancel any pending search + require("neo-tree.sources.filesystem.lib.filter_external").cancel() + + -- reset search state + if state.open_folders_before_search then + state.force_open_folders = vim.deepcopy(state.open_folders_before_search, { noref = 1 }) + else + state.force_open_folders = nil + end + state.open_folders_before_search = nil + state.search_pattern = nil + + if open_current_node then + local success, node = pcall(state.tree.get_node, state.tree) + if success and node then + local id = node:get_id() + renderer.position.set(state, id) + id = utils.remove_trailing_slash(id) + manager.navigate(state, nil, id, utils.wrap(pcall, renderer.focus_node, state, id, false)) + end + elseif refresh then + manager.navigate(state) + else + state.tree = vim.deepcopy(state.orig_tree) + end + state.orig_tree = nil +end + +---Show the filtered tree +---@param state any +---@param do_not_focus_window boolean? whether to focus the window +local show_filtered_tree = function(state, do_not_focus_window) + state.tree = vim.deepcopy(state.orig_tree) + state.tree:get_nodes()[1].search_pattern = state.search_pattern + local max_score, max_id = fzy.get_score_min(), nil + local function filter_tree(node_id) + local node = state.tree:get_node(node_id) + local path = node.extra.search_path or node.path + + local should_keep = fzy.has_match(state.search_pattern, path) + if should_keep then + local score = fzy.score(state.search_pattern, path) + node.extra.fzy_score = score + if score > max_score then + max_score = score + max_id = node_id + end + end + + if node:has_children() then + for _, child_id in ipairs(node:get_child_ids()) do + should_keep = filter_tree(child_id) or should_keep + end + end + if not should_keep then + state.tree:remove_node(node_id) -- TODO: this might not be efficient + end + return should_keep + end + if #state.search_pattern > 0 then + for _, root in ipairs(state.tree:get_nodes()) do + filter_tree(root:get_id()) + end + end + manager.redraw(state.name) + if max_id then + renderer.focus_node(state, max_id, do_not_focus_window) + end +end + +---Main entry point for the filter functionality. +---This will display a filter input popup and filter the source tree on change and on submit +---@param state table the source state +---@param search_as_you_type boolean? whether to filter as you type or only on submit +---@param keep_filter_on_submit boolean? whether to keep the filter on or reset it +M.show_filter = function(state, search_as_you_type, keep_filter_on_submit) + local winid = vim.api.nvim_get_current_win() + local height = vim.api.nvim_win_get_height(winid) + local scroll_padding = 3 + + -- setup the input popup options + local popup_msg = "Search:" + if search_as_you_type then + popup_msg = "Filter:" + end + + local width = vim.fn.winwidth(0) - 2 + local row = height - 3 + if state.current_position == "float" then + scroll_padding = 0 + width = vim.fn.winwidth(winid) + row = height - 2 + vim.api.nvim_win_set_height(winid, row) + end + + state.orig_tree = vim.deepcopy(state.tree) + + local popup_options = popups.popup_options(popup_msg, width, { + relative = "win", + winid = winid, + position = { + row = row, + col = 0, + }, + size = width, + }) + + local has_pre_search_folders = utils.truthy(state.open_folders_before_search) + if not has_pre_search_folders then + log.trace("No search or pre-search folders, recording pre-search folders now") + state.open_folders_before_search = renderer.get_expanded_nodes(state.tree) + end + + local waiting_for_default_value = utils.truthy(state.search_pattern) + local input = Input(popup_options, { + prompt = " ", + default_value = state.search_pattern, + on_submit = function(value) + if value == "" then + reset_filter(state) + return + end + if search_as_you_type and not keep_filter_on_submit then + reset_filter(state, true, true) + return + end + -- do the search + state.search_pattern = value + show_filtered_tree(state, false) + end, + --this can be bad in a deep folder structure + on_change = function(value) + if not search_as_you_type then + return + end + -- apparently when a default value is set, on_change fires for every character + if waiting_for_default_value then + if #value < #state.search_pattern then + return + end + waiting_for_default_value = false + end + if value == state.search_pattern or value == nil then + return + end + + -- finally do the search + log.trace("Setting search in on_change to: " .. value) + state.search_pattern = value + local len_to_delay = { [0] = 500, 500, 400, 200 } + local delay = len_to_delay[#value] or 100 + + utils.debounce(state.name .. "_filter", function() + show_filtered_tree(state, true) + end, delay, utils.debounce_strategy.CALL_LAST_ONLY) + end, + }) + + input:mount() + + local restore_height = vim.schedule_wrap(function() + if vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_height(winid, height) + end + end) + + -- create mappings and autocmd + input:map("i", "", "", { noremap = true }) + input:map("i", "", function(bufnr) + vim.cmd("stopinsert") + input:unmount() + if utils.truthy(state.search_pattern) then + reset_filter(state, true) + end + restore_height() + end, { noremap = true }) + + local config = require("neo-tree").config + for lhs, cmd_name in pairs(config.filesystem.window.fuzzy_finder_mappings) do + local cmd = cmds[cmd_name] + if cmd then + input:map("i", lhs, utils.wrap(cmd, state, scroll_padding), { noremap = true }) + else + log.warn(string.format("Invalid command in fuzzy_finder_mappings: %s = %s", lhs, cmd_name)) + end + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/common/help.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/help.lua new file mode 100644 index 000000000..216eaf41b --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/common/help.lua @@ -0,0 +1,93 @@ +local Popup = require("nui.popup") +local NuiLine = require("nui.line") +local utils = require("neo-tree.utils") +local popups = require("neo-tree.ui.popups") +local highlights = require("neo-tree.ui.highlights") +local M = {} + +local add_text = function(text, highlight) + local line = NuiLine() + line:append(text, highlight) + return line +end + +M.show = function(state) + local tree_width = vim.api.nvim_win_get_width(state.winid) + local keys = utils.get_keys(state.resolved_mappings, true) + + local lines = { add_text("") } + lines[1] = add_text(" Press the corresponding key to execute the command.", "Comment") + lines[2] = add_text(" Press to cancel.", "Comment") + lines[3] = add_text("") + local header = NuiLine() + header:append(string.format(" %14s", "KEY(S)"), highlights.ROOT_NAME) + header:append(" ", highlights.DIM_TEXT) + header:append("COMMAND", highlights.ROOT_NAME) + lines[4] = header + local max_width = #lines[1]:content() + for _, key in ipairs(keys) do + local value = state.resolved_mappings[key] + local nline = NuiLine() + nline:append(string.format(" %14s", key), highlights.FILTER_TERM) + nline:append(" -> ", highlights.DIM_TEXT) + nline:append(value.text, highlights.NORMAL) + local line = nline:content() + if #line > max_width then + max_width = #line + end + table.insert(lines, nline) + end + + local width = math.min(60, max_width + 1) + + if state.current_position == "right" then + col = vim.o.columns - tree_width - width - 1 + else + col = tree_width - 1 + end + + local options = { + position = { + row = 2, + col = col, + }, + size = { + width = width, + height = #keys + 5, + }, + enter = true, + focusable = true, + zindex = 50, + relative = "editor", + } + local options = popups.popup_options("Neotree Help", width, options) + local popup = Popup(options) + popup:mount() + + popup:map("n", "", function() + popup:unmount() + end, { noremap = true }) + + local event = require("nui.utils.autocmd").event + popup:on({ event.BufLeave, event.BufDelete }, function() + popup:unmount() + end, { once = true }) + + for _, key in ipairs(keys) do + -- map everything except for + if string.match(key:lower(), "^ 0 and instance then + instance:preview(bufnr, position, end_position) + end +end + +Preview.toggle = function(state) + if toggle_state then + Preview.hide() + else + Preview.show(state) + if instance and instance.active then + toggle_state = true + else + Preview.hide() + return + end + local winid = state.winid + local source_name = state.name + local preview_event = { + event = events.VIM_CURSOR_MOVED, + handler = function() + if not toggle_state or vim.api.nvim_get_current_win() == instance.winid then + return + end + if vim.api.nvim_get_current_win() == winid then + log.debug("Cursor moved in tree window, updating preview") + Preview.show(state) + else + log.debug("Neo-tree window lost focus, disposing preview") + Preview.hide() + end + end, + id = "preview-event", + } + instance:subscribe(source_name, preview_event) + end +end + +Preview.focus = function() + if Preview.is_active() then + vim.fn.win_gotoid(instance.winid) + end +end + +return Preview diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/commands.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/commands.lua new file mode 100644 index 000000000..5e87a2e65 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/commands.lua @@ -0,0 +1,69 @@ +--This file should contain all commands meant to be used by mappings. +local cc = require("neo-tree.sources.common.commands") +local utils = require("neo-tree.utils") +local manager = require("neo-tree.sources.manager") +local inputs = require("neo-tree.ui.inputs") +local filters = require("neo-tree.sources.common.filters") + +local vim = vim + +local M = {} +local SOURCE_NAME = "document_symbols" +M.refresh = utils.wrap(manager.refresh, SOURCE_NAME) +M.redraw = utils.wrap(manager.redraw, SOURCE_NAME) + +M.show_debug_info = function(state) + print(vim.inspect(state)) +end + +M.jump_to_symbol = function(state, node) + node = node or state.tree:get_node() + if node:get_depth() == 1 then + return + end + vim.api.nvim_set_current_win(state.lsp_winid) + vim.api.nvim_set_current_buf(state.lsp_bufnr) + local symbol_loc = node.extra.selection_range.start + vim.api.nvim_win_set_cursor(state.lsp_winid, { symbol_loc[1] + 1, symbol_loc[2] }) +end + +M.rename = function(state) + local node = state.tree:get_node() + if node:get_depth() == 1 then + return + end + local old_name = node.name + + local callback = function(new_name) + if not new_name or new_name == "" or new_name == old_name then + return + end + M.jump_to_symbol(state, node) + vim.lsp.buf.rename(new_name) + M.refresh(state) + end + local msg = string.format('Enter new name for "%s":', old_name) + inputs.input(msg, old_name, callback) +end + +M.open = M.jump_to_symbol + +M.filter_on_submit = function(state) + filters.show_filter(state, true, true) +end + +M.filter = function(state) + filters.show_filter(state, true) +end + +cc._add_common_commands(M, "node") -- common tree commands +cc._add_common_commands(M, "^open") -- open commands +cc._add_common_commands(M, "^close_window$") +cc._add_common_commands(M, "source$") -- source navigation +cc._add_common_commands(M, "preview") -- preview +cc._add_common_commands(M, "^cancel$") -- cancel +cc._add_common_commands(M, "help") -- help commands +cc._add_common_commands(M, "with_window_picker$") -- open using window picker +cc._add_common_commands(M, "^toggle_auto_expand_width$") + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/components.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/components.lua new file mode 100644 index 000000000..5b8cd88f8 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/components.lua @@ -0,0 +1,41 @@ +-- This file contains the built-in components. Each componment is a function +-- that takes the following arguments: +-- config: A table containing the configuration provided by the user +-- when declaring this component in their renderer config. +-- node: A NuiNode object for the currently focused node. +-- state: The current state of the source providing the items. +-- +-- The function should return either a table, or a list of tables, each of which +-- contains the following keys: +-- text: The text to display for this item. +-- highlight: The highlight group to apply to this text. + +local highlights = require("neo-tree.ui.highlights") +local common = require("neo-tree.sources.common.components") + +local M = {} + +M.icon = function(config, node, state) + return { + text = node:get_depth() == 1 and "" or node.extra.kind.icon, + highlight = node.extra.kind.hl, + } +end + +M.kind_icon = M.icon + +M.kind_name = function(config, node, state) + return { + text = node:get_depth() == 1 and "" or node.extra.kind.name, + highlight = node.extra.kind.hl, + } +end + +M.name = function(config, node, state) + return { + text = node.name, + highlight = node.extra.kind.hl or highlights.FILE_NAME, + } +end + +return vim.tbl_deep_extend("force", common, M) diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/init.lua new file mode 100644 index 000000000..03745932f --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/init.lua @@ -0,0 +1,108 @@ +--This file should have all functions that are in the public api and either set +--or read the state of this source. + +local vim = vim +local manager = require("neo-tree.sources.manager") +local events = require("neo-tree.events") +local utils = require("neo-tree.utils") +local symbols = require("neo-tree.sources.document_symbols.lib.symbols_utils") +local renderer = require("neo-tree.ui.renderer") + +local M = { + name = "document_symbols", + display_name = "  Symbols ", +} + +local get_state = function() + return manager.get_state(M.name) +end + +---Refresh the source with debouncing +---@param args { afile: string } +local refresh_debounced = function(args) + if utils.is_real_file(args.afile) == false then + return + end + utils.debounce( + "document_symbols_refresh", + utils.wrap(manager.refresh, M.name), + 100, + utils.debounce_strategy.CALL_LAST_ONLY + ) +end + +---Internal function to follow the cursor +local follow_symbol = function() + local state = get_state() + if state.lsp_bufnr ~= vim.api.nvim_get_current_buf() then + return + end + local cursor = vim.api.nvim_win_get_cursor(state.lsp_winid) + local node_id = symbols.get_symbol_by_loc(state.tree, { cursor[1] - 1, cursor[2] }) + if #node_id > 0 then + renderer.focus_node(state, node_id, true) + end +end + +---Follow the cursor with debouncing +---@param args { afile: string } +local follow_debounced = function(args) + if utils.is_real_file(args.afile) == false then + return + end + utils.debounce( + "document_symbols_follow", + utils.wrap(follow_symbol, args.afile), + 100, + utils.debounce_strategy.CALL_LAST_ONLY + ) +end + +---Navigate to the given path. +M.navigate = function(state) + state.lsp_winid, _ = utils.get_appropriate_window(state) + state.lsp_bufnr = vim.api.nvim_win_get_buf(state.lsp_winid) + state.path = vim.api.nvim_buf_get_name(state.lsp_bufnr) + + symbols.render_symbols(state) +end + +---Configures the plugin, should be called before the plugin is used. +---@param config table Configuration table containing any keys that the user +---wants to change from the defaults. May be empty to accept default values. +M.setup = function(config, global_config) + symbols.setup(config) + + if config.before_render then + manager.subscribe(M.name, { + event = events.BEFORE_RENDER, + handler = function(state) + local this_state = get_state() + if state == this_state then + config.before_render(this_state) + end + end, + }) + end + + local refresh_events = { + events.VIM_BUFFER_ENTER, + events.VIM_INSERT_LEAVE, + events.VIM_TEXT_CHANGED_NORMAL, + } + for _, event in ipairs(refresh_events) do + manager.subscribe(M.name, { + event = event, + handler = refresh_debounced, + }) + end + + if config.follow_cursor then + manager.subscribe(M.name, { + event = events.VIM_CURSOR_MOVED, + handler = follow_debounced, + }) + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/client_filters.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/client_filters.lua new file mode 100644 index 000000000..2273cba47 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/client_filters.lua @@ -0,0 +1,88 @@ +---Utilities function to filter the LSP servers +local utils = require("neo-tree.utils") + +---@alias LspRespRaw table +local M = {} + +---@alias FilterFn fun(client_name: string): boolean + +---Filter clients +---@param filter_type "first" | "all" +---@param filter_fn FilterFn +---@param resp LspRespRaw +---@return table +local filter_clients = function(filter_type, filter_fn, resp) + if resp == nil or type(resp) ~= "table" then + return {} + end + filter_fn = filter_fn or function(client_name) + return true + end + + local result = {} + for client_id, client_resp in pairs(resp) do + local client_name = vim.lsp.get_client_by_id(client_id).name + if filter_fn(client_name) and client_resp.result ~= nil then + result[client_name] = client_resp.result + if filter_type ~= "all" then + break + end + end + end + return result +end + +---Filter only allowed clients +---@param allow_only string[] the list of clients to keep +---@return FilterFn +local allow_only = function(allow_only) + return function(client_name) + return vim.tbl_contains(allow_only, client_name) + end +end + +---Ignore clients +---@param ignore string[] the list of clients to remove +---@return FilterFn +local ignore = function(ignore) + return function(client_name) + return not vim.tbl_contains(ignore, client_name) + end +end + +---Main entry point for the filter +---@param resp LspRespRaw +---@return table +M.filter_resp = function(resp) + return {} +end + +---Setup the filter accordingly to the config +---@see neo-tree-document-symbols-source for more details on options that the filter accepts +---@param cfg_flt "first" | "all" | { type: "first" | "all", fn: FilterFn, allow_only: string[], ignore: string[] } +M.setup = function(cfg_flt) + local filter_type = "first" + local filter_fn = nil + + if type(cfg_flt) == "table" then + if cfg_flt.type == "all" then + filter_type = "all" + end + + if cfg_flt.fn ~= nil then + filter_fn = cfg_flt.fn + elseif cfg_flt.allow_only then + filter_fn = allow_only(cfg_flt.allow_only) + elseif cfg_flt.ignore then + filter_fn = ignore(cfg_flt.ignore) + end + elseif cfg_flt == "all" then + filter_type = "all" + end + + M.filter_resp = function(resp) + return filter_clients(filter_type, filter_fn, resp) + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/kinds.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/kinds.lua new file mode 100644 index 000000000..50440bb02 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/kinds.lua @@ -0,0 +1,62 @@ +---Helper module to render symbols' kinds +---Need to be initialized by calling M.setup() +local M = {} + +local kinds_id_to_name = { + [0] = "Root", + [1] = "File", + [2] = "Module", + [3] = "Namespace", + [4] = "Package", + [5] = "Class", + [6] = "Method", + [7] = "Property", + [8] = "Field", + [9] = "Constructor", + [10] = "Enum", + [11] = "Interface", + [12] = "Function", + [13] = "Variable", + [14] = "Constant", + [15] = "String", + [16] = "Number", + [17] = "Boolean", + [18] = "Array", + [19] = "Object", + [20] = "Key", + [21] = "Null", + [22] = "EnumMember", + [23] = "Struct", + [24] = "Event", + [25] = "Operator", + [26] = "TypeParameter", +} + +local kinds_map = {} + +---Get how the kind with kind_id should be rendered +---@param kind_id integer the kind_id to be render +---@return table res of the form { name = kind_display_name, icon = kind_icon, hl = kind_hl } +M.get_kind = function(kind_id) + local kind_name = kinds_id_to_name[kind_id] + return vim.tbl_extend( + "force", + { name = kind_name or ("Unknown: " .. kind_id), icon = "?", hl = "" }, + kind_name and (kinds_map[kind_name] or {}) or kinds_map["Unknown"] + ) +end + +---Setup the module with custom kinds +---@param custom_kinds table additional kinds, should be of the form { [kind_id] = kind_name } +---@param kinds_display table mapping of kind_name to corresponding display name, icon and hl group +--- { [kind_name] = { +--- name = kind_display_name, +--- icon = kind_icon, +--- hl = kind_hl +--- }, } +M.setup = function(custom_kinds, kinds_display) + kinds_id_to_name = vim.tbl_deep_extend("force", kinds_id_to_name, custom_kinds or {}) + kinds_map = kinds_display +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/symbols_utils.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/symbols_utils.lua new file mode 100644 index 000000000..491fcc3ed --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/document_symbols/lib/symbols_utils.lua @@ -0,0 +1,210 @@ +---Utilities functions for the document_symbols source +local renderer = require("neo-tree.ui.renderer") +local filters = require("neo-tree.sources.document_symbols.lib.client_filters") +local kinds = require("neo-tree.sources.document_symbols.lib.kinds") + +local M = {} + +---@alias Loc integer[] a location in a buffer {row, col}, 0-indexed +---@alias LocRange { start: Loc, ["end"]: Loc } a range consisting of two loc + +---@class SymbolExtra +---@field bufnr integer the buffer containing the symbols, +---@field kind string the kind of each symbol +---@field selection_range LocRange the symbol's location +---@field position Loc start of symbol's definition +---@field end_position Loc start of symbol's definition + +---@class SymbolNode see +---@field id string +---@field name string name of symbol +---@field path string buffer path - should all be the same +---@field type "root"|"symbol" +---@field children SymbolNode[] +---@field extra SymbolExtra additional info + +---@alias LspLoc { line: integer, character: integer} +---@alias LspRange { start : LspLoc, ["end"]: LspLoc } +---@class LspRespNode +---@field name string +---@field detail string? +---@field kind integer +---@field tags any +---@field deprecated boolean? +---@field range LspRange +---@field selectionRange LspRange +---@field children LspRespNode[] + +---Parse the LspRange +---@param range LspRange the LspRange object to parse +---@return LocRange range the parsed range +local parse_range = function(range) + return { + start = { range.start.line, range.start.character }, + ["end"] = { range["end"].line, range["end"].character }, + } +end + +---Compare two tuples of length 2 by first - second elements +---@param a Loc +---@param b Loc +---@return boolean +local loc_less_than = function(a, b) + if a[1] < b[1] then + return true + elseif a[1] == b[1] then + return a[2] <= b[2] + end + return false +end + +---Check whether loc is contained in range, i.e range[1] <= loc <= range[2] +---@param loc Loc +---@param range LocRange +---@return boolean +M.is_loc_in_range = function(loc, range) + return loc_less_than(range[1], loc) and loc_less_than(loc, range[2]) +end + +---Get the the current symbol under the cursor +---@param tree any the Nui symbol tree +---@param loc Loc the cursor location {row, col} (0-index) +---@return string node_id +M.get_symbol_by_loc = function(tree, loc) + local function dfs(node) + local node_id = node:get_id() + if node:has_children() then + for _, child in ipairs(tree:get_nodes(node_id)) do + if M.is_loc_in_range(loc, { child.extra.position, child.extra.end_position }) then + return dfs(child) + end + end + end + return node_id + end + + for _, root in ipairs(tree:get_nodes()) do + local node_id = dfs(root) + if node_id ~= root:get_id() then + return node_id + end + end + return "" +end + +---Parse the LSP response into a tree. Each node on the tree follows +---the same structure as a NuiTree node, with the extra field +---containing additional information. +---@param resp_node LspRespNode the LSP response node +---@param id string the id of the current node +---@return SymbolNode symb_node the parsed tree +local function parse_resp(resp_node, id, state, parent_search_path) + -- parse all children + local children = {} + local search_path = parent_search_path .. "/" .. resp_node.name + for i, child in ipairs(resp_node.children or {}) do + local child_node = parse_resp(child, id .. "." .. i, state, search_path) + table.insert(children, child_node) + end + + -- parse current node + local preview_range = parse_range(resp_node.range) + local symb_node = { + id = id, + name = resp_node.name, + type = "symbol", + path = state.path, + children = children, + extra = { + bufnr = state.lsp_bufnr, + kind = kinds.get_kind(resp_node.kind), + selection_range = parse_range(resp_node.selectionRange), + search_path = search_path, + -- detail = resp_node.detail, + position = preview_range.start, + end_position = preview_range["end"], + }, + } + return symb_node +end + +---Callback function for lsp request +---@param lsp_resp LspRespRaw the response of the lsp client +---@param state table the state of the source +local on_lsp_resp = function(lsp_resp, state) + if lsp_resp == nil or type(lsp_resp) ~= "table" then + return + end + + -- filter the response to get only the desired LSP + local resp = filters.filter_resp(lsp_resp) + + local bufname = state.path + local items = {} + + -- parse each client's response + for client_name, client_result in pairs(resp) do + local symbol_list = {} + for i, resp_node in ipairs(client_result) do + table.insert(symbol_list, parse_resp(resp_node, #items .. "." .. i, state, "/")) + end + + -- add the parsed response to the tree + local splits = vim.split(bufname, "/") + local filename = splits[#splits] + table.insert(items, { + id = "" .. #items, + name = string.format("SYMBOLS (%s) in %s", client_name, filename), + path = bufname, + type = "root", + children = symbol_list, + extra = { kind = kinds.get_kind(0), search_path = "/" }, + }) + end + renderer.show_nodes(items, state) +end + +M.render_symbols = function(state) + local bufnr = state.lsp_bufnr + local bufname = state.path + + -- if no client found, terminate + local client_found = false + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = bufnr })) do + if client.server_capabilities.documentSymbolProvider then + client_found = true + break + end + end + if not client_found then + local splits = vim.split(bufname, "/") + renderer.show_nodes({ + { + id = "0", + name = "No client found for " .. splits[#splits], + path = bufname, + type = "root", + children = {}, + extra = { kind = kinds.get_kind(0), search_path = "/" }, + }, + }, state) + return + end + + -- client found + vim.lsp.buf_request_all( + bufnr, + "textDocument/documentSymbol", + { textDocument = vim.lsp.util.make_text_document_params(bufnr) }, + function(resp) + on_lsp_resp(resp, state) + end + ) +end + +M.setup = function(config) + filters.setup(config.client_filters) + kinds.setup(config.custom_kinds, config.kinds) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/commands.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/commands.lua new file mode 100644 index 000000000..160ca604d --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/commands.lua @@ -0,0 +1,248 @@ +--This file should contain all commands meant to be used by mappings. + +local cc = require("neo-tree.sources.common.commands") +local fs = require("neo-tree.sources.filesystem") +local utils = require("neo-tree.utils") +local filter = require("neo-tree.sources.filesystem.lib.filter") +local renderer = require("neo-tree.ui.renderer") +local log = require("neo-tree.log") + +local M = {} +local refresh = function(state) + fs._navigate_internal(state, nil, nil, nil, false) +end + +local redraw = function(state) + renderer.redraw(state) +end + +M.add = function(state) + cc.add(state, utils.wrap(fs.show_new_children, state)) +end + +M.add_directory = function(state) + cc.add_directory(state, utils.wrap(fs.show_new_children, state)) +end + +M.clear_filter = function(state) + fs.reset_search(state, true) +end + +M.copy = function(state) + cc.copy(state, utils.wrap(refresh, state)) +end + +---Marks node as copied, so that it can be pasted somewhere else. +M.copy_to_clipboard = function(state) + cc.copy_to_clipboard(state, utils.wrap(redraw, state)) +end + +M.copy_to_clipboard_visual = function(state, selected_nodes) + cc.copy_to_clipboard_visual(state, selected_nodes, utils.wrap(redraw, state)) +end + +---Marks node as cut, so that it can be pasted (moved) somewhere else. +M.cut_to_clipboard = function(state) + cc.cut_to_clipboard(state, utils.wrap(redraw, state)) +end + +M.cut_to_clipboard_visual = function(state, selected_nodes) + cc.cut_to_clipboard_visual(state, selected_nodes, utils.wrap(redraw, state)) +end + +M.move = function(state) + cc.move(state, utils.wrap(refresh, state)) +end + +---Pastes all items from the clipboard to the current directory. +M.paste_from_clipboard = function(state) + cc.paste_from_clipboard(state, utils.wrap(fs.show_new_children, state)) +end + +M.delete = function(state) + cc.delete(state, utils.wrap(refresh, state)) +end + +M.delete_visual = function(state, selected_nodes) + cc.delete_visual(state, selected_nodes, utils.wrap(refresh, state)) +end + +M.expand_all_nodes = function(state) + local toggle_dir_no_redraw = function(_state, node) + fs.toggle_directory(_state, node, nil, true, true) + end + cc.expand_all_nodes(state, toggle_dir_no_redraw) +end + +---Shows the filter input, which will filter the tree. +M.filter_as_you_type = function(state) + filter.show_filter(state, true) +end + +---Shows the filter input, which will filter the tree. +M.filter_on_submit = function(state) + filter.show_filter(state, false) +end + +---Shows the filter input in fuzzy finder mode. +M.fuzzy_finder = function(state) + filter.show_filter(state, true, true) +end + +---Shows the filter input in fuzzy finder mode. +M.fuzzy_finder_directory = function(state) + filter.show_filter(state, true, "directory") +end + +---Shows the filter input in fuzzy sorter +M.fuzzy_sorter = function(state) + filter.show_filter(state, true, true, true) +end + +---Shows the filter input in fuzzy sorter with only directories +M.fuzzy_sorter_directory = function(state) + filter.show_filter(state, true, "directory", true) +end + +---Navigate up one level. +M.navigate_up = function(state) + local parent_path, _ = utils.split_path(state.path) + if not utils.truthy(parent_path) then + return + end + local path_to_reveal = nil + local node = state.tree:get_node() + if node then + path_to_reveal = node:get_id() + end + if state.search_pattern then + fs.reset_search(state, false) + end + log.debug("Changing directory to:", parent_path) + fs._navigate_internal(state, parent_path, path_to_reveal, nil, false) +end + +local focus_next_git_modified = function(state, reverse) + local node = state.tree:get_node() + local current_path = node:get_id() + local g = state.git_status_lookup + if not utils.truthy(g) then + return + end + local paths = { current_path } + for path, status in pairs(g) do + if path ~= current_path and status and status ~= "!!" then + --don't include files not in the current working directory + if utils.is_subpath(state.path, path) then + table.insert(paths, path) + end + end + end + local sorted_paths = utils.sort_by_tree_display(paths) + if reverse then + sorted_paths = utils.reverse_list(sorted_paths) + end + + local is_file = function(path) + local success, stats = pcall(vim.loop.fs_stat, path) + return (success and stats and stats.type ~= "directory") + end + + local passed = false + local target = nil + for _, path in ipairs(sorted_paths) do + if target == nil and is_file(path) then + target = path + end + if passed then + if is_file(path) then + target = path + break + end + elseif path == current_path then + passed = true + end + end + + local existing = state.tree:get_node(target) + if existing then + renderer.focus_node(state, target) + else + fs.navigate(state, state.path, target, nil, false) + end +end + +M.next_git_modified = function(state) + focus_next_git_modified(state, false) +end + +M.prev_git_modified = function(state) + focus_next_git_modified(state, true) +end + +M.open = function(state) + cc.open(state, utils.wrap(fs.toggle_directory, state)) +end +M.open_split = function(state) + cc.open_split(state, utils.wrap(fs.toggle_directory, state)) +end +M.open_vsplit = function(state) + cc.open_vsplit(state, utils.wrap(fs.toggle_directory, state)) +end +M.open_tabnew = function(state) + cc.open_tabnew(state, utils.wrap(fs.toggle_directory, state)) +end +M.open_drop = function(state) + cc.open_drop(state, utils.wrap(fs.toggle_directory, state)) +end +M.open_tab_drop = function(state) + cc.open_tab_drop(state, utils.wrap(fs.toggle_directory, state)) +end + +M.open_with_window_picker = function(state) + cc.open_with_window_picker(state, utils.wrap(fs.toggle_directory, state)) +end +M.split_with_window_picker = function(state) + cc.split_with_window_picker(state, utils.wrap(fs.toggle_directory, state)) +end +M.vsplit_with_window_picker = function(state) + cc.vsplit_with_window_picker(state, utils.wrap(fs.toggle_directory, state)) +end + +M.refresh = refresh + +M.rename = function(state) + cc.rename(state, utils.wrap(refresh, state)) +end + +M.set_root = function(state) + local tree = state.tree + local node = tree:get_node() + if node.type == "directory" then + if state.search_pattern then + fs.reset_search(state, false) + end + fs._navigate_internal(state, node.id, nil, nil, false) + end +end + +---Toggles whether hidden files are shown or not. +M.toggle_hidden = function(state) + state.filtered_items.visible = not state.filtered_items.visible + log.info("Toggling hidden files: " .. tostring(state.filtered_items.visible)) + refresh(state) +end + +---Toggles whether the tree is filtered by gitignore or not. +M.toggle_gitignore = function(state) + log.warn("`toggle_gitignore` has been removed, running toggle_hidden instead.") + M.toggle_hidden(state) +end + +M.toggle_node = function(state) + cc.toggle_node(state, utils.wrap(fs.toggle_directory, state)) +end + +cc._add_common_commands(M) + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/components.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/components.lua new file mode 100644 index 000000000..d2839aabe --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/components.lua @@ -0,0 +1,40 @@ +-- This file contains the built-in components. Each componment is a function +-- that takes the following arguments: +-- config: A table containing the configuration provided by the user +-- when declaring this component in their renderer config. +-- node: A NuiNode object for the currently focused node. +-- state: The current state of the source providing the items. +-- +-- The function should return either a table, or a list of tables, each of which +-- contains the following keys: +-- text: The text to display for this item. +-- highlight: The highlight group to apply to this text. + +local highlights = require("neo-tree.ui.highlights") +local common = require("neo-tree.sources.common.components") +local utils = require("neo-tree.utils") + +local M = {} + +M.current_filter = function(config, node, state) + local filter = node.search_pattern or "" + if filter == "" then + return {} + end + return { + { + text = "Find", + highlight = highlights.DIM_TEXT, + }, + { + text = string.format('"%s"', filter), + highlight = config.highlight or highlights.FILTER_TERM, + }, + { + text = "in", + highlight = highlights.DIM_TEXT, + }, + } +end + +return vim.tbl_deep_extend("force", common, M) diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/init.lua new file mode 100644 index 000000000..df85b9c8b --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/init.lua @@ -0,0 +1,431 @@ +--This file should have all functions that are in the public api and either set +--or read the state of this source. + +local vim = vim +local utils = require("neo-tree.utils") +local fs_scan = require("neo-tree.sources.filesystem.lib.fs_scan") +local renderer = require("neo-tree.ui.renderer") +local events = require("neo-tree.events") +local log = require("neo-tree.log") +local manager = require("neo-tree.sources.manager") +local git = require("neo-tree.git") +local glob = require("neo-tree.sources.filesystem.lib.globtopattern") + +local M = { + name = "filesystem", + display_name = "  Files ", +} + +local wrap = function(func) + return utils.wrap(func, M.name) +end + +local get_state = function(tabid) + return manager.get_state(M.name, tabid) +end + +-- TODO: DEPRECATED in 1.19, remove in 2.0 +-- Leaving this here for now because it was mentioned in the help file. +M.reveal_current_file = function() + log.warn("DEPRECATED: use `neotree.sources.manager.reveal_current_file('filesystem')` instead") + return manager.reveal_current_file(M.name) +end + +local follow_internal = function(callback, force_show, async) + log.trace("follow called") + if vim.bo.filetype == "neo-tree" or vim.bo.filetype == "neo-tree-popup" then + return + end + local path_to_reveal = manager.get_path_to_reveal() + if not utils.truthy(path_to_reveal) then + return false + end + + local state = get_state() + if state.current_position == "float" then + return false + end + if not state.path then + return false + end + local window_exists = renderer.window_exists(state) + if window_exists then + local node = state.tree and state.tree:get_node() + if node then + if node:get_id() == path_to_reveal then + -- already focused + return false + end + end + else + if not force_show then + return false + end + end + + local is_in_path = path_to_reveal:sub(1, #state.path) == state.path + if not is_in_path then + return false + end + + log.debug("follow file: ", path_to_reveal) + local show_only_explicitly_opened = function() + local eod = state.explicitly_opened_directories or {} + local expanded_nodes = renderer.get_expanded_nodes(state.tree) + local state_changed = false + for _, id in ipairs(expanded_nodes) do + local is_explicit = eod[id] + if not is_explicit then + local is_in_path = path_to_reveal:sub(1, #id) == id + if is_in_path then + is_explicit = true + end + end + if not is_explicit then + local node = state.tree:get_node(id) + if node then + node:collapse() + state_changed = true + end + end + if state_changed then + renderer.redraw(state) + end + end + end + + state.position.is.restorable = false -- we will handle setting cursor position here + fs_scan.get_items(state, nil, path_to_reveal, function() + show_only_explicitly_opened() + renderer.focus_node(state, path_to_reveal, true) + if type(callback) == "function" then + callback() + end + end, async) + return true +end + +M.follow = function(callback, force_show) + if vim.fn.bufname(0) == "COMMIT_EDITMSG" then + return false + end + if utils.is_floating() then + return false + end + utils.debounce("neo-tree-follow", function() + return follow_internal(callback, force_show) + end, 100, utils.debounce_strategy.CALL_LAST_ONLY) +end + +M._navigate_internal = function(state, path, path_to_reveal, callback, async) + log.trace("navigate_internal", state.current_position, path, path_to_reveal) + state.dirty = false + local is_search = utils.truthy(state.search_pattern) + local path_changed = false + if not path and not state.bind_to_cwd then + path = state.path + end + if path == nil then + log.debug("navigate_internal: path is nil, using cwd") + path = manager.get_cwd(state) + end + if path ~= state.path then + log.debug("navigate_internal: path changed from ", state.path, " to ", path) + state.path = path + path_changed = true + end + + if path_to_reveal then + renderer.position.set(state, path_to_reveal) + log.debug( + "navigate_internal: in path_to_reveal, state.position is ", + state.position.node_id, + ", restorable = ", + state.position.is.restorable + ) + fs_scan.get_items(state, nil, path_to_reveal, callback) + else + local is_current = state.current_position == "current" + local follow_file = state.follow_current_file + and not is_search + and not is_current + and manager.get_path_to_reveal() + local handled = false + if utils.truthy(follow_file) then + handled = follow_internal(callback, true, async) + end + if not handled then + local success, msg = pcall(renderer.position.save, state) + if success then + log.trace("navigate_internal: position saved") + else + log.trace("navigate_internal: FAILED to save position: ", msg) + end + fs_scan.get_items(state, nil, nil, callback, async) + end + end + + if path_changed and state.bind_to_cwd then + manager.set_cwd(state) + end + local config = require("neo-tree").config + if config.enable_git_status and not is_search and config.git_status_async then + git.status_async(state.path, state.git_base, config.git_status_async_options) + end +end + +---Navigate to the given path. +---@param path string? Path to navigate to. If empty, will navigate to the cwd. +---@param path_to_reveal string? Node to focus after the items are loaded. +---@param callback function? Callback to call after the items are loaded. +M.navigate = function(state, path, path_to_reveal, callback, async) + log.trace("navigate", path, path_to_reveal, async) + utils.debounce("filesystem_navigate", function() + M._navigate_internal(state, path, path_to_reveal, callback, async) + end, utils.debounce_strategy.CALL_FIRST_AND_LAST, 100) +end + +M.reset_search = function(state, refresh, open_current_node) + log.trace("reset_search") + -- Cancel any pending search + require("neo-tree.sources.filesystem.lib.filter_external").cancel() + -- reset search state + state.fuzzy_finder_mode = nil + state.use_fzy = nil + state.fzy_sort_result_scores = nil + state.fzy_sort_file_list_cache = nil + state.sort_function_override = nil + + if refresh == nil then + refresh = true + end + if state.open_folders_before_search then + state.force_open_folders = vim.deepcopy(state.open_folders_before_search, { noref = 1 }) + else + state.force_open_folders = nil + end + state.search_pattern = nil + state.open_folders_before_search = nil + if open_current_node then + local success, node = pcall(state.tree.get_node, state.tree) + if success and node then + local path = node:get_id() + renderer.position.set(state, path) + if node.type == "directory" then + path = utils.remove_trailing_slash(path) + log.trace("opening directory from search: ", path) + M.navigate(state, nil, path, function() + pcall(renderer.focus_node, state, path, false) + end) + else + utils.open_file(state, path) + if + refresh + and state.current_position ~= "current" + and state.current_position ~= "float" + then + M.navigate(state, nil, path) + end + end + end + elseif refresh then + M.navigate(state) + end +end + +M.show_new_children = function(state, node_or_path) + local node = node_or_path + if node_or_path == nil then + node = state.tree:get_node() + node_or_path = node:get_id() + elseif type(node_or_path) == "string" then + node = state.tree:get_node(node_or_path) + if node == nil then + local parent_path, _ = utils.split_path(node_or_path) + node = state.tree:get_node(parent_path) + if node == nil then + M.navigate(state, nil, node_or_path) + return + end + end + else + node = node_or_path + node_or_path = node:get_id() + end + + if node.type ~= "directory" then + return + end + + M.navigate(state, nil, node_or_path) +end + +---Configures the plugin, should be called before the plugin is used. +---@param config table Configuration table containing any keys that the user +--wants to change from the defaults. May be empty to accept default values. +M.setup = function(config, global_config) + config.filtered_items = config.filtered_items or {} + config.enable_git_status = global_config.enable_git_status + + for _, key in ipairs({ "hide_by_pattern", "never_show_by_pattern" }) do + local list = config.filtered_items[key] + if type(list) == "table" then + for i, pattern in ipairs(list) do + list[i] = glob.globtopattern(pattern) + end + end + end + + for _, key in ipairs({ "hide_by_name", "always_show", "never_show" }) do + local list = config.filtered_items[key] + if type(list) == "table" then + config.filtered_items[key] = utils.list_to_dict(list) + end + end + + --Configure events for before_render + if config.before_render then + --convert to new event system + manager.subscribe(M.name, { + event = events.BEFORE_RENDER, + handler = function(state) + local this_state = get_state() + if state == this_state then + config.before_render(this_state) + end + end, + }) + elseif global_config.enable_git_status and global_config.git_status_async then + manager.subscribe(M.name, { + event = events.GIT_STATUS_CHANGED, + handler = wrap(manager.git_status_changed), + }) + elseif global_config.enable_git_status then + manager.subscribe(M.name, { + event = events.BEFORE_RENDER, + handler = function(state) + local this_state = get_state() + if state == this_state then + state.git_status_lookup = git.status(state.git_base) + end + end, + }) + end + + -- Respond to git events from git_status source or Fugitive + if global_config.enable_git_status then + manager.subscribe(M.name, { + event = events.GIT_EVENT, + handler = function() + manager.refresh(M.name) + end, + }) + end + + --Configure event handlers for file changes + if config.use_libuv_file_watcher then + manager.subscribe(M.name, { + event = events.FS_EVENT, + handler = wrap(manager.refresh), + }) + else + require("neo-tree.sources.filesystem.lib.fs_watch").unwatch_all() + if global_config.enable_refresh_on_write then + manager.subscribe(M.name, { + event = events.VIM_BUFFER_CHANGED, + handler = function(arg) + local afile = arg.afile or "" + if utils.is_real_file(afile) then + log.trace("refreshing due to vim_buffer_changed event: ", afile) + manager.refresh("filesystem") + else + log.trace("Ignoring vim_buffer_changed event for non-file: ", afile) + end + end, + }) + end + end + + --Configure event handlers for cwd changes + if config.bind_to_cwd then + manager.subscribe(M.name, { + event = events.VIM_DIR_CHANGED, + handler = wrap(manager.dir_changed), + }) + end + + --Configure event handlers for lsp diagnostic updates + if global_config.enable_diagnostics then + manager.subscribe(M.name, { + event = events.VIM_DIAGNOSTIC_CHANGED, + handler = wrap(manager.diagnostics_changed), + }) + end + + --Configure event handlers for modified files + if global_config.enable_modified_markers then + manager.subscribe(M.name, { + event = events.VIM_BUFFER_MODIFIED_SET, + handler = wrap(manager.opened_buffers_changed), + }) + end + + if global_config.enable_opened_markers then + for _, event in ipairs({ events.VIM_BUFFER_ADDED, events.VIM_BUFFER_DELETED }) do + manager.subscribe(M.name, { + event = event, + handler = wrap(manager.opened_buffers_changed), + }) + end + end + + -- Configure event handler for follow_current_file option + if config.follow_current_file then + manager.subscribe(M.name, { + event = events.VIM_BUFFER_ENTER, + handler = function(args) + if utils.is_real_file(args.afile) then + M.follow() + end + end, + }) + end +end + +---Expands or collapses the current node. +M.toggle_directory = function(state, node, path_to_reveal, skip_redraw, recursive) + local tree = state.tree + if not node then + node = tree:get_node() + end + if node.type ~= "directory" then + return + end + state.explicitly_opened_directories = state.explicitly_opened_directories or {} + if node.loaded == false then + local id = node:get_id() + state.explicitly_opened_directories[id] = true + renderer.position.set(state, nil) + fs_scan.get_items(state, id, path_to_reveal, nil, false, recursive) + elseif node:has_children() then + local updated = false + if node:is_expanded() then + updated = node:collapse() + state.explicitly_opened_directories[node:get_id()] = false + else + updated = node:expand() + state.explicitly_opened_directories[node:get_id()] = true + end + if updated and not skip_redraw then + renderer.redraw(state) + end + if path_to_reveal then + renderer.focus_node(state, path_to_reveal) + end + elseif require("neo-tree").config.filesystem.scan_mode == "deep" then + node.empty_expanded = not node.empty_expanded + renderer.redraw(state) + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/filter.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/filter.lua new file mode 100644 index 000000000..8618ce356 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/filter.lua @@ -0,0 +1,233 @@ +-- This file holds all code for the search function. + +local vim = vim +local Input = require("nui.input") +local event = require("nui.utils.autocmd").event +local fs = require("neo-tree.sources.filesystem") +local popups = require("neo-tree.ui.popups") +local renderer = require("neo-tree.ui.renderer") +local utils = require("neo-tree.utils") +local log = require("neo-tree.log") +local manager = require("neo-tree.sources.manager") + +local M = {} + +local cmds = { + move_cursor_down = function(state, scroll_padding) + renderer.focus_node(state, nil, true, 1, scroll_padding) + end, + + move_cursor_up = function(state, scroll_padding) + renderer.focus_node(state, nil, true, -1, scroll_padding) + vim.cmd("redraw!") + end, +} + +local function create_input_mapping_handle(cmd, state, scroll_padding) + return function() + cmd(state, scroll_padding) + end +end + +M.show_filter = function(state, search_as_you_type, fuzzy_finder_mode, use_fzy) + local popup_options + local winid = vim.api.nvim_get_current_win() + local height = vim.api.nvim_win_get_height(winid) + local scroll_padding = 3 + local popup_msg = "Search:" + + if search_as_you_type then + if fuzzy_finder_mode == "directory" then + popup_msg = "Filter Directories:" + else + popup_msg = "Filter:" + end + end + if state.current_position == "float" then + scroll_padding = 0 + local width = vim.fn.winwidth(winid) + local row = height - 2 + vim.api.nvim_win_set_height(winid, row) + popup_options = popups.popup_options(popup_msg, width, { + relative = "win", + winid = winid, + position = { + row = row, + col = 0, + }, + size = width, + }) + else + local width = vim.fn.winwidth(0) - 2 + local row = height - 3 + popup_options = popups.popup_options(popup_msg, width, { + relative = "win", + winid = winid, + position = { + row = row, + col = 0, + }, + size = width, + }) + end + + local sort_by_score = function(a, b) + -- `state.fzy_sort_result_scores` should be defined in + -- `sources.filesystem.lib.filter_external.fzy_sort_files` + local result_scores = state.fzy_sort_result_scores or { foo = 0, baz = 0 } + local a_score = result_scores[a.path] + local b_score = result_scores[b.path] + if a_score == nil or b_score == nil then + log.debug(string.format([[Fzy: failed to compare %s: %s, %s: %s]], a.path, a_score, b.path, b_score)) + local config = require("neo-tree").config + if config.sort_function ~= nil then + return config.sort_function(a, b) + end + return nil + end + return a_score > b_score + end + + local select_first_file = function() + local is_file = function(node) + return node.type == "file" + end + local files = renderer.select_nodes(state.tree, is_file, 1) + if #files > 0 then + renderer.focus_node(state, files[1]:get_id(), true) + end + end + + local has_pre_search_folders = utils.truthy(state.open_folders_before_search) + if not has_pre_search_folders then + log.trace("No search or pre-search folders, recording pre-search folders now") + state.open_folders_before_search = renderer.get_expanded_nodes(state.tree) + end + + local waiting_for_default_value = utils.truthy(state.search_pattern) + local input = Input(popup_options, { + prompt = " ", + default_value = state.search_pattern, + on_submit = function(value) + if value == "" then + fs.reset_search(state) + else + if search_as_you_type and fuzzy_finder_mode then + fs.reset_search(state, true, true) + return + end + state.search_pattern = value + manager.refresh("filesystem", function() + -- focus first file + local nodes = renderer.get_all_visible_nodes(state.tree) + for _, node in ipairs(nodes) do + if node.type == "file" then + renderer.focus_node(state, node:get_id(), false) + break + end + end + end) + end + end, + --this can be bad in a deep folder structure + on_change = function(value) + if not search_as_you_type then + return + end + -- apparently when a default value is set, on_change fires for every character + if waiting_for_default_value then + if #value < #state.search_pattern then + return + else + waiting_for_default_value = false + end + end + if value == state.search_pattern then + return + elseif value == nil then + return + elseif value == "" then + if state.search_pattern == nil then + return + end + log.trace("Resetting search in on_change") + local original_open_folders = nil + if type(state.open_folders_before_search) == "table" then + original_open_folders = vim.deepcopy(state.open_folders_before_search, { noref = 1 }) + end + fs.reset_search(state) + state.open_folders_before_search = original_open_folders + else + log.trace("Setting search in on_change to: " .. value) + state.search_pattern = value + state.fuzzy_finder_mode = fuzzy_finder_mode + if use_fzy then + state.sort_function_override = sort_by_score + state.use_fzy = true + end + local callback = select_first_file + if fuzzy_finder_mode == "directory" then + callback = nil + end + + local len = #value + local delay = 500 + if len > 3 then + delay = 100 + elseif len > 2 then + delay = 200 + elseif len > 1 then + delay = 400 + end + + utils.debounce("filesystem_filter", function() + fs._navigate_internal(state, nil, nil, callback) + end, delay, utils.debounce_strategy.CALL_LAST_ONLY) + end + end, + }) + + input:mount() + + local restore_height = vim.schedule_wrap(function() + if vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_height(winid, height) + end + end) + input:map("i", "", function(bufnr) + vim.cmd("stopinsert") + input:unmount() + if fuzzy_finder_mode and utils.truthy(state.search_pattern) then + fs.reset_search(state, true) + end + restore_height() + end, { noremap = true }) + + input:map("i", "", "", { noremap = true }) + + input:on({ event.BufLeave, event.BufDelete }, function() + vim.cmd("stopinsert") + input:unmount() + -- If this was closed due to submit, that function will handle the reset_search + vim.defer_fn(function() + if fuzzy_finder_mode and utils.truthy(state.search_pattern) then + fs.reset_search(state, true) + end + end, 100) + restore_height() + end, { once = true }) + + if fuzzy_finder_mode then + local config = require("neo-tree").config + for lhs, cmd_name in pairs(config.filesystem.window.fuzzy_finder_mappings) do + local cmd = cmds[cmd_name] + if cmd then + input:map("i", lhs, create_input_mapping_handle(cmd, state, scroll_padding), { noremap = true }) + else + log.warn(string.format('Invalid command in fuzzy_finder_mappings: %s = %s', lhs, cmd_name)) + end + end + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/filter_external.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/filter_external.lua new file mode 100644 index 000000000..a8f1ed0e8 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/filter_external.lua @@ -0,0 +1,392 @@ +local vim = vim +local log = require("neo-tree.log") +local Job = require("plenary.job") +local utils = require("neo-tree.utils") +local Queue = require("neo-tree.collections").Queue + +local M = {} +local fd_supports_max_results = nil +local unpack = unpack or table.unpack + +local test_for_max_results = function(cmd) + if fd_supports_max_results == nil then + if cmd == "fd" or cmd == "fdfind" then + --test if it supports the max-results option + local test = vim.fn.system(cmd .. " this_is_only_a_test --max-depth=1 --max-results=1") + if test:match("^error:") then + fd_supports_max_results = false + log.debug(cmd, "does NOT support max-results") + else + fd_supports_max_results = true + log.debug(cmd, "supports max-results") + end + end + end +end + +local get_find_command = function(state) + if state.find_command then + test_for_max_results(state.find_command) + return state.find_command + end + + if 1 == vim.fn.executable("fdfind") then + state.find_command = "fdfind" + elseif 1 == vim.fn.executable("fd") then + state.find_command = "fd" + elseif 1 == vim.fn.executable("find") and vim.fn.has("win32") == 0 then + state.find_command = "find" + elseif 1 == vim.fn.executable("where") then + state.find_command = "where" + end + + test_for_max_results(state.find_command) + return state.find_command +end + +local running_jobs = Queue:new() +local kill_job = function(job) + local pid = job.pid + job:shutdown() + if pid ~= nil and pid > 0 then + if utils.is_windows then + vim.fn.system("taskkill /F /T /PID " .. pid) + else + vim.fn.system("kill -9 " .. pid) + end + end + return true +end + +M.cancel = function() + if running_jobs:is_empty() then + return + end + running_jobs:for_each(kill_job) +end + +---@class FileTypes +---@field file boolean +---@field directory boolean +---@field symlink boolean +---@field socket boolean +---@field pipe boolean +---@field executable boolean +---@field empty boolean +---@field block boolean Only for `find` +---@field character boolean Only for `find` + +---filter_files_external +-- Spawns a filter command based on `cmd` +---@param cmd string Command to execute. Use `get_find_command` most times. +---@param path string Base directory to start the search. +---@param glob string | nil If not nil, do glob search. Take precedence on `regex` +---@param regex string | nil If not nil, do regex search if command supports. if glob ~= nil, ignored +---@param full_path boolean If true, search agaist the absolute path +---@param types FileTypes | nil Return only true filetypes. If nil, all are returned. +---@param ignore { dotfiles: boolean?, gitignore: boolean? } If true, ignored from result. Default: false +---@param limit? integer | nil Maximim number of results. nil will return everything. +---@param find_args? string[] | table Any additional options passed to command if any. +---@param on_insert? fun(err: string, line: string): any Executed for each line of stdout and stderr. +---@param on_exit? fun(return_val: table): any Executed at the end. +M.filter_files_external = function( + cmd, + path, + glob, + regex, + full_path, + types, + ignore, + limit, + find_args, + on_insert, + on_exit +) + if glob ~= nil and regex ~= nil then + local log_msg = string.format([[glob: %s, regex: %s]], glob, regex) + log.warn("both glob and regex are set. glob will take precedence. " .. log_msg) + end + ignore = ignore or {} + types = types or {} + limit = limit or math.huge -- math.huge == no limit + local file_type_map = { + file = "f", + directory = "d", + symlink = "l", + socket = "s", + pipe = "p", + executable = "x", -- only for `fd` + empty = "e", -- only for `fd` + block = "b", -- only for `find` + character = "c", -- only for `find` + } + + local args = {} + local function append(...) + for _, v in pairs({ ... }) do + if v ~= nil then + args[#args + 1] = v + end + end + end + + local function append_find_args() + if find_args then + if type(find_args) == "string" then + append(find_args) + elseif type(find_args) == "table" then + if find_args[1] then + append(unpack(find_args)) + elseif find_args[cmd] then + append(unpack(find_args[cmd])) ---@diagnostic disable-line + end + elseif type(find_args) == "function" then + args = find_args(cmd, path, glob, args) + end + end + end + + if cmd == "fd" or cmd == "fdfind" then + if not ignore.dotfiles then + append("--hidden") + end + if not ignore.gitignore then + append("--no-ignore") + end + append("--color", "never") + if fd_supports_max_results and 0 < limit and limit < math.huge then + append("--max-results", limit) + end + for k, v in pairs(types) do + if v and file_type_map[k] ~= nil then + append("--type", k) + end + end + if full_path then + append("--full-path") + if glob ~= nil then + local words = utils.split(glob, " ") + regex = ".*" .. table.concat(words, ".*") .. ".*" + glob = nil + end + end + if glob ~= nil then + append("--glob") + end + append_find_args() + append("--", glob or regex or "") + append(path) + elseif cmd == "find" then + append(path) + local file_types = {} + for k, v in pairs(types) do + if v and file_type_map[k] ~= nil then + file_types[#file_types + 1] = file_type_map[k] + end + end + if #file_types > 0 then + append("-type", table.concat(file_types, ",")) + end + if types.empty then + append("-empty") + end + if types.executable then + append("-executable") + end + if not ignore.dotfiles then + append("-not", "-path", "*/.*") + end + if glob ~= nil and not full_path then + append("-iname", glob) + elseif glob ~= nil and full_path then + local words = utils.split(glob, " ") + regex = ".*" .. table.concat(words, ".*") .. ".*" + append("-regextype", "sed", "-regex", regex) + elseif regex ~= nil then + append("-regextype", "sed", "-regex", regex) + end + append_find_args() + elseif cmd == "fzf" then + -- This does not work yet, there's some kind of issue with how fzf uses stdout + error("fzf is not a supported find_command") + append_find_args() + append("--no-sort", "--no-expect", "--filter", glob or regex) -- using the raw term without glob patterns + elseif cmd == "where" then + append_find_args() + append("/r", path, glob or regex) + else + return { "No search command found!" } + end + + if fd_supports_max_results then + limit = math.huge -- `fd` manages limit on its own + end + local item_count = 0 + local job = Job:new({ + command = cmd, + cwd = path, + args = args, + enable_recording = false, + on_stdout = function(err, line) + if item_count < limit and on_insert then + on_insert(err, line) + item_count = item_count + 1 + end + end, + on_stderr = function(err, line) + if item_count < limit and on_insert then + on_insert(err or line, line) + -- item_count = item_count + 1 + end + end, + on_exit = function(_, return_val) + if on_exit then + on_exit(return_val) + end + end, + }) + + -- This ensures that only one job is running at a time + running_jobs:for_each(kill_job) + running_jobs:add(job) + job:start() +end + +local function fzy_sort_get_total_score(terms, path) + local fzy = require("neo-tree.sources.common.filters.filter_fzy") + local total_score = 0 + for _, term in ipairs(terms) do -- spaces in `opts.term` are treated as `and` + local score = fzy.score(term, path) + if score == fzy.get_score_min() then -- if any not found, end searching + return 0 + end + total_score = total_score + score + end + return total_score +end + +local function modify_parent_scores(result_scores, path, score) + local parent, _ = utils.split_path(path) + while parent ~= nil do -- back propagate the score to its ancesters + if score > (result_scores[parent] or 0) then + result_scores[parent] = score + parent, _ = utils.split_path(parent) + else + break + end + end +end + +M.fzy_sort_files = function(opts, state) + state = state or {} + local filters = opts.filtered_items + local limit = opts.limit or 100 + local full_path_words = opts.find_by_full_path_words + local fuzzy_finder_mode = opts.fuzzy_finder_mode + local pwd = opts.path + if pwd:sub(-1) ~= "/" then + pwd = pwd .. "/" + end + local pwd_length = #pwd + local terms = {} + for term in string.gmatch(opts.term, "[^%s]+") do -- space split opts.term + terms[#terms + 1] = term + end + + -- The base search is anything that contains the characters in the term + -- The fzy score is then used to sort the results + local chars = {} + local regex = ".*" + local chars_to_escape = + { "%", "+", "-", "?", "[", "^", "$", "(", ")", "{", "}", "=", "!", "<", ">", "|", ":", "#" } + for _, term in ipairs(terms) do + for c in term:gmatch(".") do + if not chars[c] then + chars[c] = true + if chars_to_escape[c] then + c = [[\]] .. c + end + regex = regex .. c .. ".*" + end + end + end + + local result_counter = 0 + + local index = 1 + state.fzy_sort_result_scores = {} + local function on_insert(err, path) + if not err then + local relative_path = path + if not full_path_words and #path > pwd_length and path:sub(1, pwd_length) == pwd then + relative_path = "./" .. path:sub(pwd_length + 1) + end + index = index + 1 + if state.fzy_sort_result_scores == nil then + state.fzy_sort_result_scores = {} + end + state.fzy_sort_result_scores[path] = 0 + local score = fzy_sort_get_total_score(terms, relative_path) + if score > 0 then + state.fzy_sort_result_scores[path] = score + result_counter = result_counter + 1 + modify_parent_scores(state.fzy_sort_result_scores, path, score) + opts.on_insert(nil, path) + if result_counter >= limit then + vim.schedule(M.cancel) + end + end + end + end + + M.filter_files_external( + get_find_command(state), + pwd, + nil, + regex, + true, + { directory = fuzzy_finder_mode == "directory", file = fuzzy_finder_mode ~= "directory" }, + { + dotfiles = not filters.visible and filters.hide_dotfiles, + gitignore = not filters.visible and filters.hide_gitignored, + }, + nil, + opts.find_args, + on_insert, + opts.on_exit + ) +end + +M.find_files = function(opts) + local filters = opts.filtered_items + local full_path_words = opts.find_by_full_path_words + local regex, glob = nil, nil + local fuzzy_finder_mode = opts.fuzzy_finder_mode + + glob = opts.term + if glob:sub(1) ~= "*" then + glob = "*" .. glob + end + if glob:sub(-1) ~= "*" then + glob = glob .. "*" + end + + M.filter_files_external( + get_find_command(opts), + opts.path, + glob, + regex, + full_path_words, + { directory = fuzzy_finder_mode == "directory" }, + { + dotfiles = not filters.visible and filters.hide_dotfiles, + gitignore = not filters.visible and filters.hide_gitignored, + }, + opts.limit or 200, + opts.find_args, + opts.on_insert, + opts.on_exit + ) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_actions.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_actions.lua new file mode 100644 index 000000000..c96745a5d --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_actions.lua @@ -0,0 +1,591 @@ +-- This file is for functions that mutate the filesystem. + +-- This code started out as a copy from: +-- https://github.com/mhartington/dotfiles +-- and modified to fit neo-tree's api. +-- Permalink: https://github.com/mhartington/dotfiles/blob/7560986378753e0c047d940452cb03a3b6439b11/config/nvim/lua/mh/filetree/init.lua +local vim = vim +local api = vim.api +local loop = vim.loop +local scan = require("plenary.scandir") +local utils = require("neo-tree.utils") +local inputs = require("neo-tree.ui.inputs") +local events = require("neo-tree.events") +local log = require("neo-tree.log") +local Path = require("plenary").path + +local M = {} + +local function clear_buffer(path) + local buf = utils.find_buffer_by_name(path) + if buf < 1 then + return + end + local alt = vim.fn.bufnr("#") + -- Check all windows to see if they are using the buffer + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf then + -- if there is no alternate buffer yet, create a blank one now + if alt < 1 or alt == buf then + alt = vim.api.nvim_create_buf(true, false) + end + -- replace the buffer displayed in this window with the alternate buffer + vim.api.nvim_win_set_buf(win, alt) + end + end + local success, msg = pcall(vim.api.nvim_buf_delete, buf, { force = true }) + if not success then + log.error("Could not clear buffer: ", msg) + end +end + +---Opens new_buf in each window that has old_buf currently open. +---Useful during file rename. +---@param old_buf number +---@param new_buf number +local function replace_buffer_in_windows(old_buf, new_buf) + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == old_buf then + vim.api.nvim_win_set_buf(win, new_buf) + end + end +end + +local function rename_buffer(old_path, new_path) + local force_save = function() + vim.cmd("silent! write!") + end + + for _, buf in pairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + local buf_name = vim.api.nvim_buf_get_name(buf) + local new_buf_name = nil + if old_path == buf_name then + new_buf_name = new_path + elseif utils.is_subpath(old_path, buf_name) then + new_buf_name = new_path .. buf_name:sub(#old_path + 1) + end + if utils.truthy(new_buf_name) then + local new_buf = vim.fn.bufadd(new_buf_name) + vim.fn.bufload(new_buf) + vim.api.nvim_buf_set_option(new_buf, "buflisted", true) + replace_buffer_in_windows(buf, new_buf) + + if vim.api.nvim_buf_get_option(buf, "buftype") == "" then + local modified = vim.api.nvim_buf_get_option(buf, "modified") + if modified then + local old_buffer_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + vim.api.nvim_buf_set_lines(new_buf, 0, -1, false, old_buffer_lines) + + local msg = buf_name .. " has been modified. Save under new name? (y/n) " + inputs.confirm(msg, function(confirmed) + if confirmed then + vim.api.nvim_buf_call(new_buf, force_save) + log.trace("Force saving renamed buffer with changes") + else + vim.cmd("echohl WarningMsg") + vim.cmd( + [[echo "Skipping force save. You'll need to save it with `:w!` when you are ready to force writing with the new name."]] + ) + vim.cmd("echohl NONE") + end + end) + end + end + vim.api.nvim_buf_delete(buf, { force = true }) + end + end + end +end + +local function create_all_parents(path) + local create_all_as_folders + function create_all_as_folders(in_path) + if not loop.fs_stat(in_path) then + local parent, _ = utils.split_path(in_path) + if parent then + create_all_as_folders(parent) + end + loop.fs_mkdir(in_path, 493) + end + end + + local parent_path, _ = utils.split_path(path) + create_all_as_folders(parent_path) +end + +-- Gets a non-existing filename from the user and executes the callback with it. +local function get_unused_name( + destination, + using_root_directory, + name_chosen_callback, + first_message +) + if loop.fs_stat(destination) then + local parent_path, name + if not using_root_directory then + parent_path, name = utils.split_path(destination) + elseif #using_root_directory > 0 then + parent_path = destination:sub(1, #using_root_directory) + name = destination:sub(#using_root_directory + 2) + else + parent_path = nil + name = destination + end + + local message = first_message or name .. " already exists. Please enter a new name: " + inputs.input(message, name, function(new_name) + if new_name and string.len(new_name) > 0 then + local new_path = parent_path and parent_path .. utils.path_separator .. new_name or new_name + get_unused_name(new_path, using_root_directory, name_chosen_callback) + end + end) + else + name_chosen_callback(destination) + end +end + +-- Move Node +M.move_node = function(source, destination, callback, using_root_directory) + log.trace( + "Moving node: ", + source, + " to ", + destination, + ", using root directory: ", + using_root_directory + ) + local _, name = utils.split_path(source) + get_unused_name(destination or source, using_root_directory, function(dest) + local function move_file() + create_all_parents(dest) + loop.fs_rename(source, dest, function(err) + if err then + log.error("Could not move the files from", source, "to", dest, ":", err) + return + end + vim.schedule(function() + rename_buffer(source, dest) + end) + vim.schedule(function() + events.fire_event(events.FILE_MOVED, { + source = source, + destination = dest, + }) + if callback then + callback(source, dest) + end + end) + end) + end + local event_result = events.fire_event(events.BEFORE_FILE_MOVE, { + source = source, + destination = dest, + callback = move_file, + }) or {} + if event_result.handled then + return + end + move_file() + end, 'Move "' .. name .. '" to:') +end + +---Plenary path.copy() when used to copy a recursive structure, can return a nested +-- table with for each file a Path instance and the success result. +---@param copy_result table The output of Path.copy() +---@param flat_result table Return value containing the flattened results +local function flatten_path_copy_result(flat_result, copy_result) + if not copy_result then + return + end + for k, v in pairs(copy_result) do + if type(v) == "table" then + flatten_path_copy_result(flat_result, v) + else + table.insert(flat_result, { destination = k.filename, success = v }) + end + end +end + +-- Check if all files were copied successfully, using the flattened copy result +local function check_path_copy_result(flat_result) + if not flat_result then + return + end + for _, file_result in ipairs(flat_result) do + if not file_result.success then + return false + end + end + return true +end + +-- Copy Node +M.copy_node = function(source, _destination, callback, using_root_directory) + local _, name = utils.split_path(source) + get_unused_name(_destination or source, using_root_directory, function(destination) + local parent_path, _ = utils.split_path(destination) + if source == parent_path then + log.warn("Cannot copy a file/folder to itself") + return + end + local source_path = Path:new(source) + if source_path:is_file() then + -- When the source is a file, then Path.copy() currently doesn't create + -- the potential non-existing parent directories of the destination. + create_all_parents(destination) + end + local success, result = pcall(source_path.copy, source_path, { + destination = destination, + recursive = true, + parents = true, + }) + if not success then + log.error("Could not copy the file(s) from", source, "to", destination, ":", result) + return + end + + -- It can happen that the Path.copy() function returns successfully but + -- the copy action still failed. In this case the copy() result contains + -- a nested table of Path instances for each file copied, and the success + -- result. + local flat_result = {} + flatten_path_copy_result(flat_result, result) + if not check_path_copy_result(flat_result) then + log.error("Could not copy the file(s) from", source, "to", destination, ":", flat_result) + return + end + + vim.schedule(function() + events.fire_event(events.FILE_ADDED, destination) + if callback then + callback(source, destination) + end + end) + end, 'Copy "' .. name .. '" to:') +end + +--- Create a new directory +M.create_directory = function(in_directory, callback, using_root_directory) + local base + if type(using_root_directory) == "string" then + if in_directory == using_root_directory then + base = "" + elseif #using_root_directory > 0 then + base = in_directory:sub(#using_root_directory + 2) .. utils.path_separator + else + base = in_directory .. utils.path_separator + end + else + base = vim.fn.fnamemodify(in_directory .. utils.path_separator, ":~") + using_root_directory = false + end + + inputs.input("Enter name for new directory:", base, function(destinations) + if not destinations then + return + end + + for _, destination in ipairs(utils.brace_expand(destinations)) do + if not destination or destination == base then + return + end + + if using_root_directory then + destination = utils.path_join(using_root_directory, destination) + else + destination = vim.fn.fnamemodify(destination, ":p") + end + + if loop.fs_stat(destination) then + log.warn("Directory already exists") + return + end + + create_all_parents(destination) + loop.fs_mkdir(destination, 493) + + vim.schedule(function() + events.fire_event(events.FILE_ADDED, destination) + if callback then + callback(destination) + end + end) + end + end) +end + +--- Create Node +M.create_node = function(in_directory, callback, using_root_directory) + local base + if type(using_root_directory) == "string" then + if in_directory == using_root_directory then + base = "" + elseif #using_root_directory > 0 then + base = in_directory:sub(#using_root_directory + 2) .. utils.path_separator + else + base = in_directory .. utils.path_separator + end + else + base = vim.fn.fnamemodify(in_directory .. utils.path_separator, ":~") + using_root_directory = false + end + + inputs.input( + 'Enter name for new file or directory (dirs end with a "/"):', + base, + function(destinations) + if not destinations then + return + end + + for _, destination in ipairs(utils.brace_expand(destinations)) do + if not destination or destination == base then + return + end + local is_dir = vim.endswith(destination, "/") + + if using_root_directory then + destination = utils.path_join(using_root_directory, destination) + else + destination = vim.fn.fnamemodify(destination, ":p") + end + + if loop.fs_stat(destination) then + log.warn("File already exists") + return + end + + create_all_parents(destination) + if is_dir then + loop.fs_mkdir(destination, 493) + else + local open_mode = loop.constants.O_CREAT + + loop.constants.O_WRONLY + + loop.constants.O_TRUNC + local fd = loop.fs_open(destination, "w", open_mode) + if not fd then + if not loop.fs_stat(destination) then + api.nvim_err_writeln("Could not create file " .. destination) + return + else + log.warn("Failed to complete file creation of " .. destination) + end + else + loop.fs_chmod(destination, 420) + loop.fs_close(fd) + end + end + + vim.schedule(function() + events.fire_event(events.FILE_ADDED, destination) + if callback then + callback(destination) + end + end) + end + end + ) +end + +-- Delete Node +M.delete_node = function(path, callback, noconfirm) + local _, name = utils.split_path(path) + local msg = string.format("Are you sure you want to delete '%s'?", name) + + log.trace("Deleting node: ", path) + local _type = "unknown" + local stat = loop.fs_stat(path) + if stat then + _type = stat.type + if _type == "link" then + local link_to = loop.fs_readlink(path) + if not link_to then + log.error("Could not read link") + return + end + _type = loop.fs_stat(link_to) + end + if _type == "directory" then + local children = scan.scan_dir(path, { + hidden = true, + respect_gitignore = false, + add_dirs = true, + depth = 1, + }) + if #children > 0 then + msg = "WARNING: Dir not empty! " .. msg + end + end + else + log.warn("Could not read file/dir:", path, stat, ", attempting to delete anyway...") + -- Guess the type by whether it appears to have an extension + if path:match("%.(.+)$") then + _type = "file" + else + _type = "directory" + end + return + end + + local do_delete = function(confirmed) + if not confirmed then + return + end + + local function delete_dir(dir_path) + local handle = loop.fs_scandir(dir_path) + if type(handle) == "string" then + return api.nvim_err_writeln(handle) + end + + while true do + local child_name, t = loop.fs_scandir_next(handle) + if not child_name then + break + end + + local child_path = dir_path .. "/" .. child_name + if t == "directory" then + local success = delete_dir(child_path) + if not success then + log.error("failed to delete ", child_path) + return false + end + else + local success = loop.fs_unlink(child_path) + if not success then + return false + end + clear_buffer(child_path) + end + end + return loop.fs_rmdir(dir_path) + end + + if _type == "directory" then + -- first try using native system commands, which are recursive + local success = false + if utils.is_windows then + local result = vim.fn.system({ "cmd.exe", "/c", "rmdir", "/s", "/q", path }) + local error = vim.v.shell_error + if error ~= 0 then + log.debug("Could not delete directory '", path, "' with rmdir: ", result) + else + log.info("Deleted directory ", path) + success = true + end + else + local result = vim.fn.system({ "rm", "-Rf", path }) + local error = vim.v.shell_error + if error ~= 0 then + log.debug("Could not delete directory '", path, "' with rm: ", result) + else + log.info("Deleted directory ", path) + success = true + end + end + -- Fallback to using libuv if native commands fail + if not success then + success = delete_dir(path) + if not success then + return api.nvim_err_writeln("Could not remove directory: " .. path) + end + end + else + local success = loop.fs_unlink(path) + if not success then + return api.nvim_err_writeln("Could not remove file: " .. path) + end + clear_buffer(path) + end + + vim.schedule(function() + events.fire_event(events.FILE_DELETED, path) + if callback then + callback(path) + end + end) + end + + if noconfirm then + do_delete(true) + else + inputs.confirm(msg, do_delete) + end +end + +M.delete_nodes = function(paths_to_delete, callback) + local msg = "Are you sure you want to delete " .. #paths_to_delete .. " items?" + inputs.confirm(msg, function(confirmed) + if not confirmed then + return + end + + for _, path in ipairs(paths_to_delete) do + M.delete_node(path, nil, true) + end + + if callback then + vim.schedule(function() + callback(paths_to_delete[#paths_to_delete]) + end) + end + end) +end + +-- Rename Node +M.rename_node = function(path, callback) + local parent_path, name = utils.split_path(path) + local msg = string.format('Enter new name for "%s":', name) + + inputs.input(msg, name, function(new_name) + -- If cancelled + if not new_name or new_name == "" then + log.info("Operation canceled") + return + end + + local destination = parent_path .. utils.path_separator .. new_name + -- If aleady exists + if loop.fs_stat(destination) then + log.warn(destination, " already exists") + return + end + + local complete = vim.schedule_wrap(function() + rename_buffer(path, destination) + events.fire_event(events.FILE_RENAMED, { + source = path, + destination = destination, + }) + if callback then + callback(path, destination) + end + log.info("Renamed " .. new_name .. " successfully") + end) + + local function fs_rename() + loop.fs_rename(path, destination, function(err) + if err then + log.warn("Could not rename the files") + return + else + complete() + end + end) + end + + local event_result = events.fire_event(events.BEFORE_FILE_RENAME, { + source = path, + destination = destination, + callback = fs_rename, + }) or {} + if event_result.handled then + return + end + fs_rename() + end) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_scan.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_scan.lua new file mode 100644 index 000000000..682ebf827 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_scan.lua @@ -0,0 +1,505 @@ +-- This files holds code for scanning the filesystem to build the tree. +local uv = vim.loop + +local renderer = require("neo-tree.ui.renderer") +local utils = require("neo-tree.utils") +local filter_external = require("neo-tree.sources.filesystem.lib.filter_external") +local file_items = require("neo-tree.sources.common.file-items") +local log = require("neo-tree.log") +local fs_watch = require("neo-tree.sources.filesystem.lib.fs_watch") +local git = require("neo-tree.git") +local events = require("neo-tree.events") +local async = require("plenary.async") + +local Path = require("plenary.path") +local os_sep = Path.path.sep + +local M = {} + +local on_directory_loaded = function(context, dir_path) + local state = context.state + local scanned_folder = context.folders[dir_path] + if scanned_folder then + scanned_folder.loaded = true + end + if state.use_libuv_file_watcher then + local root = context.folders[dir_path] + if root then + local target_path = root.is_link and root.link_to or root.path + local fs_watch_callback = vim.schedule_wrap(function(err, fname) + if err then + log.error("file_event_callback: ", err) + return + end + if context.is_a_never_show_file(fname) then + -- don't fire events for nodes that are designated as "never show" + return + else + events.fire_event(events.FS_EVENT, { afile = target_path }) + end + end) + + log.trace("Adding fs watcher for ", target_path) + fs_watch.watch_folder(target_path, fs_watch_callback) + end + end +end + +local dir_complete = function(context, dir_path) + local paths_to_load = context.paths_to_load + local folders = context.folders + + on_directory_loaded(context, dir_path) + + -- check to see if there are more folders to load + local next_path = nil + while #paths_to_load > 0 and not next_path do + next_path = table.remove(paths_to_load) + -- ensure that the path is still valid + local success, result = pcall(vim.loop.fs_stat, next_path) + -- ensure that the result is a directory + if success and result and result.type == "directory" then + -- ensure that it is not already loaded + local existing = folders[next_path] + if existing and existing.loaded then + next_path = nil + end + else + -- if the path doesn't exist, skip it + next_path = nil + end + end + return next_path +end + +local render_context = function(context) + local state = context.state + local root = context.root + local parent_id = context.parent_id + + if not parent_id and state.use_libuv_file_watcher and state.enable_git_status then + log.trace("Starting .git folder watcher") + local path = root.path + if root.is_link then + path = root.link_to + end + fs_watch.watch_git_index(path, require("neo-tree").config.git_status_async) + end + fs_watch.updated_watched() + + if root and root.children then + file_items.deep_sort(root.children, state.sort_function_override) + end + if parent_id then + -- lazy loading a child folder + renderer.show_nodes(root.children, state, parent_id, context.callback) + else + -- full render of the tree + renderer.show_nodes({ root }, state, nil, context.callback) + end + + context.state = nil + context.callback = nil + context.all_items = nil + context.root = nil + context.parent_id = nil + context = nil +end + +local job_complete = function(context) + local state = context.state + local parent_id = context.parent_id + if #context.all_items == 0 then + log.info("No items, skipping git ignored/status lookups") + render_context(context) + return + end + if state.filtered_items.hide_gitignored or state.enable_git_status then + if require("neo-tree").config.git_status_async then + git.mark_ignored(state, context.all_items, function(all_items) + if parent_id then + vim.list_extend(state.git_ignored, all_items) + else + state.git_ignored = all_items + end + vim.schedule(function() + render_context(context) + end) + end) + return + else + local all_items = git.mark_ignored(state, context.all_items) + if parent_id then + vim.list_extend(state.git_ignored, all_items) + else + state.git_ignored = all_items + end + end + end + render_context(context) +end + +local function create_node(context, node) + local success3, item = pcall(file_items.create_item, context, node.path, node.type) +end + +local function process_node(context, path) + on_directory_loaded(context, path) +end + +local function get_children_sync(path) + local children = {} + local success, dir = pcall(vim.loop.fs_opendir, path, nil, 1000) + if not success then + log.error("Error opening dir:", dir) + end + local success2, stats = pcall(vim.loop.fs_readdir, dir) + if success2 and stats then + for _, stat in ipairs(stats) do + local child_path = utils.path_join(path, stat.name) + table.insert(children, { path = child_path, type = stat.type }) + end + end + pcall(vim.loop.fs_closedir, dir) + return children +end + +local function get_children_async(path, callback) + uv.fs_opendir(path, function(_, dir) + uv.fs_readdir(dir, function(_, stats) + local children = {} + if stats then + for _, stat in ipairs(stats) do + local child_path = utils.path_join(path, stat.name) + table.insert(children, { path = child_path, type = stat.type }) + end + end + uv.fs_closedir(dir) + callback(children) + end) + end, 1000) +end + +local function scan_dir_sync(context, path) + process_node(context, path) + local children = get_children_sync(path) + for _, child in ipairs(children) do + create_node(context, child) + if child.type == "directory" then + local grandchild_nodes = get_children_sync(child.path) + if + grandchild_nodes == nil + or #grandchild_nodes == 0 + or #grandchild_nodes == 1 and grandchild_nodes[1].type == "directory" + then + scan_dir_sync(context, child.path) + end + end + end +end + +local function scan_dir_async(context, path, callback) + get_children_async(path, function(children) + for _, child in ipairs(children) do + create_node(context, child) + if child.type == "directory" then + local grandchild_nodes = get_children_sync(child.path) + if + grandchild_nodes == nil + or #grandchild_nodes == 0 + or #grandchild_nodes == 1 and grandchild_nodes[1].type == "directory" + then + scan_dir_sync(context, child.path) + end + end + end + process_node(context, path) + callback(path) + end) +end + +-- async_scan scans all the directories in context.paths_to_load +-- and adds them as items to render in the UI. +local function async_scan(context, path) + log.trace("async_scan: ", path) + local scan_mode = require("neo-tree").config.filesystem.scan_mode + + if scan_mode == "deep" then + local scan_tasks = {} + for _, p in ipairs(context.paths_to_load) do + local scan_task = async.wrap(function(callback) + scan_dir_async(context, p, callback) + end, 1) + table.insert(scan_tasks, scan_task) + end + + async.util.run_all( + scan_tasks, + vim.schedule_wrap(function() + job_complete(context) + end) + ) + return + end + + -- scan_mode == "shallow" + context.directories_scanned = 0 + context.directories_to_scan = #context.paths_to_load + + context.on_exit = vim.schedule_wrap(function() + job_complete(context) + end) + + -- from https://github.com/nvim-lua/plenary.nvim/blob/master/lua/plenary/scandir.lua + local function read_dir(current_dir, ctx) + uv.fs_opendir(current_dir, function(err, dir) + if err then + log.error(current_dir, ": ", err) + return + end + local function on_fs_readdir(err, entries) + if err then + log.error(current_dir, ": ", err) + return + end + if entries then + for _, entry in ipairs(entries) do + local success, item = pcall( + file_items.create_item, + ctx, + utils.path_join(current_dir, entry.name), + entry.type + ) + if success then + if ctx.recursive and item.type == "directory" then + ctx.directories_to_scan = ctx.directories_to_scan + 1 + table.insert(ctx.paths_to_load, item.path) + end + else + log.error("error creating item for ", path) + end + end + + uv.fs_readdir(dir, on_fs_readdir) + return + end + uv.fs_closedir(dir) + on_directory_loaded(ctx, current_dir) + ctx.directories_scanned = ctx.directories_scanned + 1 + if ctx.directories_scanned == #ctx.paths_to_load then + ctx.on_exit() + end + + --local next_path = dir_complete(ctx, current_dir) + --if next_path then + -- local success, error = pcall(read_dir, next_path) + -- if not success then + -- log.error(next_path, ": ", error) + -- end + --else + -- on_exit() + --end + end + + uv.fs_readdir(dir, on_fs_readdir) + end) + end + + --local first = table.remove(context.paths_to_load) + --local success, err = pcall(read_dir, first) + --if not success then + -- log.error(first, ": ", err) + --end + for i = 1, context.directories_to_scan do + read_dir(context.paths_to_load[i], context) + end +end + +local function sync_scan(context, path_to_scan) + log.trace("sync_scan: ", path_to_scan) + local scan_mode = require("neo-tree").config.filesystem.scan_mode + if scan_mode == "deep" then + for _, path in ipairs(context.paths_to_load) do + scan_dir_sync(context, path) + -- scan_dir(context, path) + end + job_complete(context) + else -- scan_mode == "shallow" + local success, dir = pcall(vim.loop.fs_opendir, path_to_scan, nil, 1000) + if not success then + log.error("Error opening dir:", dir) + end + local success2, stats = pcall(vim.loop.fs_readdir, dir) + if success2 and stats then + for _, stat in ipairs(stats) do + local path = utils.path_join(path_to_scan, stat.name) + local success3, item = pcall(file_items.create_item, context, path, stat.type) + if success3 then + if context.recursive and stat.type == "directory" then + table.insert(context.paths_to_load, path) + end + else + log.error("error creating item for ", path) + end + end + end + vim.loop.fs_closedir(dir) + + local next_path = dir_complete(context, path_to_scan) + if next_path then + sync_scan(context, next_path) + else + job_complete(context) + end + end +end + +M.get_items_sync = function(state, parent_id, path_to_reveal, callback) + return M.get_items(state, parent_id, path_to_reveal, callback, false) +end + +M.get_items_async = function(state, parent_id, path_to_reveal, callback) + M.get_items(state, parent_id, path_to_reveal, callback, true) +end + +M.get_items = function(state, parent_id, path_to_reveal, callback, async, recursive) + if state.async_directory_scan == "always" then + async = true + elseif state.async_directory_scan == "never" then + async = false + elseif type(async) == "nil" then + async = (state.async_directory_scan == "auto") or state.async_directory_scan + end + + if not parent_id then + M.stop_watchers(state) + end + local context = file_items.create_context() + context.state = state + context.parent_id = parent_id + context.path_to_reveal = path_to_reveal + context.recursive = recursive + context.callback = callback + + -- Create root folder + local root = file_items.create_item(context, parent_id or state.path, "directory") + root.name = vim.fn.fnamemodify(root.path, ":~") + root.loaded = true + root.search_pattern = state.search_pattern + context.root = root + context.folders[root.path] = root + state.default_expanded_nodes = state.force_open_folders or { state.path } + + if state.search_pattern then + local search_opts = { + filtered_items = state.filtered_items, + find_command = state.find_command, + limit = state.search_limit or 50, + path = root.path, + term = state.search_pattern, + find_args = state.find_args, + find_by_full_path_words = state.find_by_full_path_words, + fuzzy_finder_mode = state.fuzzy_finder_mode, + on_insert = function(err, path) + if err then + log.debug(err) + else + file_items.create_item(context, path) + end + end, + on_exit = vim.schedule_wrap(function() + job_complete(context) + end), + } + if state.use_fzy then + filter_external.fzy_sort_files(search_opts, state) + else + -- Use the external command because the plenary search is slow + filter_external.find_files(search_opts) + end + else + -- In the case of a refresh or navigating up, we need to make sure that all + -- open folders are loaded. + local path = parent_id or state.path + context.paths_to_load = {} + if parent_id == nil then + if utils.truthy(state.force_open_folders) then + for _, f in ipairs(state.force_open_folders) do + table.insert(context.paths_to_load, f) + end + elseif state.tree then + context.paths_to_load = renderer.get_expanded_nodes(state.tree, state.path) + end + -- Ensure that there are no nested files in the list of folders to load + context.paths_to_load = vim.tbl_filter(function(p) + local stats = vim.loop.fs_stat(p) + return stats and stats.type == "directory" + end, context.paths_to_load) + if path_to_reveal then + -- be sure to load all of the folders leading up to the path to reveal + local path_to_reveal_parts = utils.split(path_to_reveal, utils.path_separator) + table.remove(path_to_reveal_parts) -- remove the file name + -- add all parent folders to the list of paths to load + utils.reduce(path_to_reveal_parts, "", function(acc, part) + local current_path = utils.path_join(acc, part) + if #current_path > #path then -- within current root + table.insert(context.paths_to_load, current_path) + table.insert(state.default_expanded_nodes, current_path) + end + return current_path + end) + context.paths_to_load = utils.unique(context.paths_to_load) + end + end + + local filtered_items = state.filtered_items or {} + context.is_a_never_show_file = function(fname) + if fname then + local _, name = utils.split_path(fname) + if name then + if filtered_items.never_show and filtered_items.never_show[name] then + return true + end + if utils.is_filtered_by_pattern(filtered_items.never_show_by_pattern, fname, name) then + return true + end + end + end + return false + end + table.insert(context.paths_to_load, path) + if async then + async_scan(context, path) + else + sync_scan(context, path) + end + end +end + +M.stop_watchers = function(state) + if state.use_libuv_file_watcher and state.tree then + -- We are loaded a new root or refreshing, unwatch any folders that were + -- previously being watched. + local loaded_folders = renderer.select_nodes(state.tree, function(node) + return node.type == "directory" and node.loaded + end) + fs_watch.unwatch_git_index(state.path, require("neo-tree").config.git_status_async) + for _, folder in ipairs(loaded_folders) do + log.trace("Unwatching folder ", folder.path) + if folder.is_link then + fs_watch.unwatch_folder(folder.link_to) + else + fs_watch.unwatch_folder(folder:get_id()) + end + end + else + log.debug( + "Not unwatching folders... use_libuv_file_watcher is ", + state.use_libuv_file_watcher, + " and state.tree is ", + utils.truthy(state.tree) + ) + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_watch.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_watch.lua new file mode 100644 index 000000000..23fdde388 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/fs_watch.lua @@ -0,0 +1,177 @@ +local vim = vim +local events = require("neo-tree.events") +local log = require("neo-tree.log") +local git = require("neo-tree.git") +local utils = require("neo-tree.utils") + +local M = {} + +local flags = { + watch_entry = false, + stat = false, + recursive = false, +} + +local watched = {} + +local get_dot_git_folder = function(path, callback) + if type(callback) == "function" then + git.get_repository_root(path, function(git_root) + if git_root then + local git_folder = utils.path_join(git_root, ".git") + local stat = vim.loop.fs_stat(git_folder) + if stat and stat.type == "directory" then + callback(git_folder, git_root) + end + else + callback(nil, nil) + end + end) + else + local git_root = git.get_repository_root(path) + if git_root then + local git_folder = utils.path_join(git_root, ".git") + local stat = vim.loop.fs_stat(git_folder) + if stat and stat.type == "directory" then + return git_folder, git_root + end + end + return nil, nil + end +end + +M.show_watched = function() + local items = {} + for _, handle in pairs(watched) do + items[handle.path] = handle.references + end + log.info("Watched Folders: ", vim.inspect(items)) +end + +---Watch a directory for changes to it's children. Not recursive. +---@param path string The directory to watch. +---@param custom_callback? function The callback to call when a change is detected. +---@param allow_git_watch? boolean Allow watching of git folders. +M.watch_folder = function(path, custom_callback, allow_git_watch) + if not allow_git_watch then + if path:find("/%.git$") or path:find("/%.git/") then + -- git folders seem to throw off fs events constantly. + log.debug("watch_folder(path): Skipping git folder: ", path) + return + end + end + local h = watched[path] + if h == nil then + log.trace("Starting new fs watch on: ", path) + local callback = custom_callback + or vim.schedule_wrap(function(err, fname) + if fname and fname:match("^%.null[-]ls_.+") then + -- null-ls temp file: https://github.com/jose-elias-alvarez/null-ls.nvim/pull/1075 + return + end + if err then + log.error("file_event_callback: ", err) + return + end + events.fire_event(events.FS_EVENT, { afile = path }) + end) + h = { + handle = vim.loop.new_fs_event(), + path = path, + references = 0, + active = false, + callback = callback, + } + watched[path] = h + --w:start(path, flags, callback) + else + log.trace("Incrementing references for fs watch on: ", path) + end + h.references = h.references + 1 +end + +M.watch_git_index = function(path, async) + local function watch_git_folder(git_folder, git_root) + if git_folder then + local git_event_callback = vim.schedule_wrap(function(err, fname) + if fname and fname:match("^.+%.lock$") then + return + end + if fname and fname:match("^%._null-ls_.+") then + -- null-ls temp file: https://github.com/jose-elias-alvarez/null-ls.nvim/pull/1075 + return + end + if err then + log.error("git_event_callback: ", err) + return + end + events.fire_event(events.GIT_EVENT, { path = fname, repository = git_root }) + end) + + M.watch_folder(git_folder, git_event_callback, true) + end + end + + if async then + get_dot_git_folder(path, watch_git_folder) + else + watch_git_folder(get_dot_git_folder(path)) + end +end + +M.updated_watched = function() + for path, w in pairs(watched) do + if w.references > 0 then + if not w.active then + log.trace("References added for fs watch on: ", path, ", starting.") + w.handle:start(path, flags, w.callback) + w.active = true + end + else + if w.active then + log.trace("No more references for fs watch on: ", path, ", stopping.") + w.handle:stop() + w.active = false + end + end + end +end + +---Stop watching a directory. If there are no more references to the handle, +---it will be destroyed. Otherwise, the reference count will be decremented. +---@param path string The directory to stop watching. +M.unwatch_folder = function(path, callback_id) + local h = watched[path] + if h then + log.trace("Decrementing references for fs watch on: ", path, callback_id) + h.references = h.references - 1 + else + log.trace("(unwatch_folder) No fs watch found for: ", path) + end +end + +M.unwatch_git_index = function(path, async) + local function unwatch_git_folder(git_folder, _) + if git_folder then + M.unwatch_folder(git_folder) + end + end + + if async then + get_dot_git_folder(path, unwatch_git_folder) + else + unwatch_git_folder(get_dot_git_folder(path)) + end +end + +---Stop watching all directories. This is the nuclear option and it affects all +---sources. +M.unwatch_all = function() + for _, h in pairs(watched) do + h.handle:stop() + h.handle = nil + end + watched = {} +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/globtopattern.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/globtopattern.lua new file mode 100644 index 000000000..8f94985d8 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/filesystem/lib/globtopattern.lua @@ -0,0 +1,157 @@ +--(c) 2008-2011 David Manura. Licensed under the same terms as Lua (MIT). + +--Permission is hereby granted, free of charge, to any person obtaining a copy +--of this software and associated documentation files (the "Software"), to deal +--in the Software without restriction, including without limitation the rights +--to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--copies of the Software, and to permit persons to whom the Software is +--furnished to do so, subject to the following conditions: + +--The above copyright notice and this permission notice shall be included in +--all copies or substantial portions of the Software. + +--THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +--THE SOFTWARE. +--(end license) + +local M = { _TYPE = "module", _NAME = "globtopattern", _VERSION = "0.2.1.20120406" } + +function M.globtopattern(g) + -- Some useful references: + -- - apr_fnmatch in Apache APR. For example, + -- http://apr.apache.org/docs/apr/1.3/group__apr__fnmatch.html + -- which cites POSIX 1003.2-1992, section B.6. + + local p = "^" -- pattern being built + local i = 0 -- index in g + local c -- char at index i in g. + + -- unescape glob char + local function unescape() + if c == "\\" then + i = i + 1 + c = g:sub(i, i) + if c == "" then + p = "[^]" + return false + end + end + return true + end + + -- escape pattern char + local function escape(c) + return c:match("^%w$") and c or "%" .. c + end + + -- Convert tokens at end of charset. + local function charset_end() + while 1 do + if c == "" then + p = "[^]" + return false + elseif c == "]" then + p = p .. "]" + break + else + if not unescape() then + break + end + local c1 = c + i = i + 1 + c = g:sub(i, i) + if c == "" then + p = "[^]" + return false + elseif c == "-" then + i = i + 1 + c = g:sub(i, i) + if c == "" then + p = "[^]" + return false + elseif c == "]" then + p = p .. escape(c1) .. "%-]" + break + else + if not unescape() then + break + end + p = p .. escape(c1) .. "-" .. escape(c) + end + elseif c == "]" then + p = p .. escape(c1) .. "]" + break + else + p = p .. escape(c1) + i = i - 1 -- put back + end + end + i = i + 1 + c = g:sub(i, i) + end + return true + end + + -- Convert tokens in charset. + local function charset() + i = i + 1 + c = g:sub(i, i) + if c == "" or c == "]" then + p = "[^]" + return false + elseif c == "^" or c == "!" then + i = i + 1 + c = g:sub(i, i) + if c == "]" then + -- ignored + else + p = p .. "[^" + if not charset_end() then + return false + end + end + else + p = p .. "[" + if not charset_end() then + return false + end + end + return true + end + + -- Convert tokens. + while 1 do + i = i + 1 + c = g:sub(i, i) + if c == "" then + p = p .. "$" + break + elseif c == "?" then + p = p .. "." + elseif c == "*" then + p = p .. ".*" + elseif c == "[" then + if not charset() then + break + end + elseif c == "\\" then + i = i + 1 + c = g:sub(i, i) + if c == "" then + p = p .. "\\$" + break + end + p = p .. escape(c) + else + p = p .. escape(c) + end + end + return p +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/commands.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/commands.lua new file mode 100644 index 000000000..7b4594c87 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/commands.lua @@ -0,0 +1,71 @@ +--This file should contain all commands meant to be used by mappings. + +local vim = vim +local cc = require("neo-tree.sources.common.commands") +local utils = require("neo-tree.utils") +local manager = require("neo-tree.sources.manager") + +local M = {} + +local refresh = utils.wrap(manager.refresh, "git_status") +local redraw = utils.wrap(manager.redraw, "git_status") + +-- ---------------------------------------------------------------------------- +-- Common commands +-- ---------------------------------------------------------------------------- +M.add = function(state) + cc.add(state, refresh) +end + +M.add_directory = function(state) + cc.add_directory(state, refresh) +end + +---Marks node as copied, so that it can be pasted somewhere else. +M.copy_to_clipboard = function(state) + cc.copy_to_clipboard(state, redraw) +end + +M.copy_to_clipboard_visual = function(state, selected_nodes) + cc.copy_to_clipboard_visual(state, selected_nodes, redraw) +end + +---Marks node as cut, so that it can be pasted (moved) somewhere else. +M.cut_to_clipboard = function(state) + cc.cut_to_clipboard(state, redraw) +end + +M.cut_to_clipboard_visual = function(state, selected_nodes) + cc.cut_to_clipboard_visual(state, selected_nodes, redraw) +end + +M.copy = function(state) + cc.copy(state, redraw) +end + +M.move = function(state) + cc.move(state, redraw) +end + +---Pastes all items from the clipboard to the current directory. +M.paste_from_clipboard = function(state) + cc.paste_from_clipboard(state, refresh) +end + +M.delete = function(state) + cc.delete(state, refresh) +end + +M.delete_visual = function(state, selected_nodes) + cc.delete_visual(state, selected_nodes, refresh) +end + +M.refresh = refresh + +M.rename = function(state) + cc.rename(state, refresh) +end + +cc._add_common_commands(M) + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/components.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/components.lua new file mode 100644 index 000000000..7d1c1f72e --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/components.lua @@ -0,0 +1,44 @@ +-- This file contains the built-in components. Each componment is a function +-- that takes the following arguments: +-- config: A table containing the configuration provided by the user +-- when declaring this component in their renderer config. +-- node: A NuiNode object for the currently focused node. +-- state: The current state of the source providing the items. +-- +-- The function should return either a table, or a list of tables, each of which +-- contains the following keys: +-- text: The text to display for this item. +-- highlight: The highlight group to apply to this text. + +local highlights = require("neo-tree.ui.highlights") +local common = require("neo-tree.sources.common.components") + +local M = {} + +M.name = function(config, node, state) + local highlight = config.highlight or highlights.FILE_NAME_OPENED + local name = node.name + if node.type == "directory" then + if node:get_depth() == 1 then + highlight = highlights.ROOT_NAME + if node:has_children() then + name = "GIT STATUS for " .. name + else + name = "GIT STATUS (working tree clean) for " .. name + end + else + highlight = highlights.DIRECTORY_NAME + end + elseif config.use_git_status_colors then + local git_status = state.components.git_status({}, node, state) + if git_status and git_status.highlight then + highlight = git_status.highlight + end + end + return { + text = name, + highlight = highlight, + } +end + +return vim.tbl_deep_extend("force", common, M) diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/init.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/init.lua new file mode 100644 index 000000000..f371612e8 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/init.lua @@ -0,0 +1,94 @@ +--This file should have all functions that are in the public api and either set +--or read the state of this source. + +local vim = vim +local utils = require("neo-tree.utils") +local renderer = require("neo-tree.ui.renderer") +local items = require("neo-tree.sources.git_status.lib.items") +local events = require("neo-tree.events") +local manager = require("neo-tree.sources.manager") + +local M = { + name = "git_status", + display_name = "  Git " +} + +local wrap = function(func) + return utils.wrap(func, M.name) +end + +local get_state = function() + return manager.get_state(M.name) +end + +---Navigate to the given path. +---@param path string Path to navigate to. If empty, will navigate to the cwd. +M.navigate = function(state, path, path_to_reveal) + state.dirty = false + if path_to_reveal then + renderer.position.set(state, path_to_reveal) + end + items.get_git_status(state) +end + +M.refresh = function() + manager.refresh(M.name) +end + +---Configures the plugin, should be called before the plugin is used. +---@param config table Configuration table containing any keys that the user +--wants to change from the defaults. May be empty to accept default values. +M.setup = function(config, global_config) + if config.before_render then + --convert to new event system + manager.subscribe(M.name, { + event = events.BEFORE_RENDER, + handler = function(state) + local this_state = get_state() + if state == this_state then + config.before_render(this_state) + end + end, + }) + end + + if global_config.enable_refresh_on_write then + manager.subscribe(M.name, { + event = events.VIM_BUFFER_CHANGED, + handler = function(args) + if utils.is_real_file(args.afile) then + M.refresh() + end + end, + }) + end + + if config.bind_to_cwd then + manager.subscribe(M.name, { + event = events.VIM_DIR_CHANGED, + handler = M.refresh, + }) + end + + if global_config.enable_diagnostics then + manager.subscribe(M.name, { + event = events.VIM_DIAGNOSTIC_CHANGED, + handler = wrap(manager.diagnostics_changed), + }) + end + + --Configure event handlers for modified files + if global_config.enable_modified_markers then + manager.subscribe(M.name, { + event = events.VIM_BUFFER_MODIFIED_SET, + handler = wrap(manager.opened_buffers_changed), + }) + end + + manager.subscribe(M.name, { + event = events.GIT_EVENT, + handler = M.refresh, + }) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/lib/items.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/lib/items.lua new file mode 100644 index 000000000..af4b220d0 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/git_status/lib/items.lua @@ -0,0 +1,49 @@ +local vim = vim +local renderer = require("neo-tree.ui.renderer") +local file_items = require("neo-tree.sources.common.file-items") +local log = require("neo-tree.log") +local git = require("neo-tree.git") + +local M = {} + +---Get a table of all open buffers, along with all parent paths of those buffers. +---The paths are the keys of the table, and all the values are 'true'. +M.get_git_status = function(state) + if state.loading then + return + end + state.loading = true + local status_lookup, project_root = git.status(state.git_base, true) + state.path = project_root or state.path or vim.fn.getcwd() + local context = file_items.create_context() + context.state = state + -- Create root folder + local root = file_items.create_item(context, state.path, "directory") + root.name = vim.fn.fnamemodify(root.path, ":~") + root.loaded = true + root.search_pattern = state.search_pattern + context.folders[root.path] = root + + for path, status in pairs(status_lookup) do + local success, item = pcall(file_items.create_item, context, path, "file") + item.status = status + if success then + item.extra = { + git_status = status, + } + else + log.error("Error creating item for " .. path .. ": " .. item) + end + end + + state.git_status_lookup = status_lookup + state.default_expanded_nodes = {} + for id, _ in pairs(context.folders) do + table.insert(state.default_expanded_nodes, id) + end + file_items.deep_sort(root.children, state.sort_function_override) + renderer.show_nodes({ root }, state) + state.loading = false +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/sources/manager.lua b/bundle/neo-tree.nvim/lua/neo-tree/sources/manager.lua new file mode 100644 index 000000000..8a277a2e2 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/sources/manager.lua @@ -0,0 +1,644 @@ +--This file should have all functions that are in the public api and either set +--or read the state of this source. + +local vim = vim +local utils = require("neo-tree.utils") +local fs_scan = require("neo-tree.sources.filesystem.lib.fs_scan") +local renderer = require("neo-tree.ui.renderer") +local inputs = require("neo-tree.ui.inputs") +local events = require("neo-tree.events") +local log = require("neo-tree.log") +local fs_watch = require("neo-tree.sources.filesystem.lib.fs_watch") + +local M = {} +local source_data = {} +local all_states = {} +local default_configs = {} + +local get_source_data = function(source_name) + if source_name == nil then + error("get_source_data: source_name cannot be nil") + end + local sd = source_data[source_name] + if sd then + return sd + end + sd = { + name = source_name, + state_by_tab = {}, + state_by_win = {}, + subscriptions = {}, + } + source_data[source_name] = sd + return sd +end + +local function create_state(tabid, sd, winid) + local default_config = default_configs[sd.name] + local state = vim.deepcopy(default_config, { noref = 1 }) + state.tabid = tabid + state.id = winid or tabid + state.dirty = true + state.position = { + is = { restorable = false }, + } + state.git_base = "HEAD" + table.insert(all_states, state) + return state +end + +M._get_all_states = function() + return all_states +end + +M._for_each_state = function(source_name, action) + for _, state in ipairs(all_states) do + if source_name == nil or state.name == source_name then + action(state) + end + end +end + +---For use in tests only, completely resets the state of all sources. +---This closes all windows as well since they would be broken by this action. +M._clear_state = function() + fs_watch.unwatch_all() + renderer.close_all_floating_windows() + for _, data in pairs(source_data) do + for _, state in pairs(data.state_by_tab) do + renderer.close(state) + end + for _, state in pairs(data.state_by_win) do + renderer.close(state) + end + end + source_data = {} +end + +M.set_default_config = function(source_name, config) + if source_name == nil then + error("set_default_config: source_name cannot be nil") + end + default_configs[source_name] = config + local sd = get_source_data(source_name) + for tabid, tab_config in pairs(sd.state_by_tab) do + sd.state_by_tab[tabid] = vim.tbl_deep_extend("force", tab_config, config) + end +end + +--TODO: we need to track state per window when working with netwrw style "current" +--position. How do we know which one to return when this is called? +M.get_state = function(source_name, tabid, winid) + if source_name == nil then + error("get_state: source_name cannot be nil") + end + tabid = tabid or vim.api.nvim_get_current_tabpage() + local sd = get_source_data(source_name) + if type(winid) == "number" then + local win_state = sd.state_by_win[winid] + if not win_state then + win_state = create_state(tabid, sd, winid) + sd.state_by_win[winid] = win_state + end + return win_state + else + local tab_state = sd.state_by_tab[tabid] + if tab_state and tab_state.winid then + -- just in case tab and window get tangled up, tab state replaces window + sd.state_by_win[tab_state.winid] = nil + end + if not tab_state then + tab_state = create_state(tabid, sd) + sd.state_by_tab[tabid] = tab_state + end + return tab_state + end +end + +---Returns the state for the current buffer, assuming it is a neo-tree buffer. +---@param winid number|nil The window id to use, if nil, the current window is used. +---@return table|nil The state for the current buffer, or nil if it is not a +---neo-tree buffer. +M.get_state_for_window = function(winid) + local winid = winid or vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(winid) + local source_status, source_name = pcall(vim.api.nvim_buf_get_var, bufnr, "neo_tree_source") + local position_status, position = pcall(vim.api.nvim_buf_get_var, bufnr, "neo_tree_position") + if not source_status or not position_status then + return nil + end + + local tabid = vim.api.nvim_get_current_tabpage() + if position == "current" then + return M.get_state(source_name, tabid, winid) + else + return M.get_state(source_name, tabid, nil) + end +end + +M.get_path_to_reveal = function(include_terminals) + local win_id = vim.api.nvim_get_current_win() + local cfg = vim.api.nvim_win_get_config(win_id) + if cfg.relative > "" or cfg.external then + -- floating window, ignore + return nil + end + if vim.bo.filetype == "neo-tree" then + return nil + end + local path = vim.fn.expand("%:p") + if not utils.truthy(path) then + return nil + end + if not include_terminals and path:match("term://") then + return nil + end + return path +end + +M.subscribe = function(source_name, event) + if source_name == nil then + error("subscribe: source_name cannot be nil") + end + local sd = get_source_data(source_name) + if not sd.subscriptions then + sd.subscriptions = {} + end + if not utils.truthy(event.id) then + event.id = sd.name .. "." .. event.event + end + log.trace("subscribing to event: " .. event.id) + sd.subscriptions[event] = true + events.subscribe(event) +end + +M.unsubscribe = function(source_name, event) + if source_name == nil then + error("unsubscribe: source_name cannot be nil") + end + local sd = get_source_data(source_name) + log.trace("unsubscribing to event: " .. event.id or event.event) + if sd.subscriptions then + for sub, _ in pairs(sd.subscriptions) do + if sub.event == event.event and sub.id == event.id then + sd.subscriptions[sub] = false + events.unsubscribe(sub) + end + end + end + events.unsubscribe(event) +end + +M.unsubscribe_all = function(source_name) + if source_name == nil then + error("unsubscribe_all: source_name cannot be nil") + end + local sd = get_source_data(source_name) + if sd.subscriptions then + for event, subscribed in pairs(sd.subscriptions) do + if subscribed then + events.unsubscribe(event) + end + end + end + sd.subscriptions = {} +end + +M.close = function(source_name, at_position) + local state = M.get_state(source_name) + if at_position then + if state.current_position == at_position then + return renderer.close(state) + else + return false + end + else + return renderer.close(state) + end +end + +M.close_all = function(at_position) + local tabid = vim.api.nvim_get_current_tabpage() + for source_name, _ in pairs(source_data) do + M._for_each_state(source_name, function(state) + if state.tabid == tabid then + if at_position then + if state.current_position == at_position then + log.trace("Closing " .. source_name .. " at position " .. at_position) + pcall(renderer.close, state) + end + else + log.trace("Closing " .. source_name) + pcall(renderer.close, state) + end + end + end) + end +end + +M.close_all_except = function(except_source_name) + local tabid = vim.api.nvim_get_current_tabpage() + for source_name, _ in pairs(source_data) do + M._for_each_state(source_name, function(state) + if state.tabid == tabid and source_name ~= except_source_name then + log.trace("Closing " .. source_name) + pcall(renderer.close, state) + end + end) + end +end + +---Redraws the tree with updated diagnostics without scanning the filesystem again. +M.diagnostics_changed = function(source_name, args) + if not type(args) == "table" then + error("diagnostics_changed: args must be a table") + end + M._for_each_state(source_name, function(state) + state.diagnostics_lookup = args.diagnostics_lookup + renderer.redraw(state) + end) +end + +---Called by autocmds when the cwd dir is changed. This will change the root. +M.dir_changed = function(source_name) + M._for_each_state(source_name, function(state) + local cwd = M.get_cwd(state) + if state.path and cwd == state.path then + return + end + if renderer.window_exists(state) then + M.navigate(state, cwd) + else + state.path = nil + state.dirty = true + end + end) +end +-- +---Redraws the tree with updated git_status without scanning the filesystem again. +M.git_status_changed = function(source_name, args) + if not type(args) == "table" then + error("git_status_changed: args must be a table") + end + M._for_each_state(source_name, function(state) + if utils.is_subpath(args.git_root, state.path) then + state.git_status_lookup = args.git_status + renderer.redraw(state) + end + end) +end + +-- Vimscript functions like vim.fn.getcwd take tabpage number (tab position counting from left) +-- but API functions operate on tabpage id (as returned by nvim_tabpage_get_number). These values +-- get out of sync when tabs are being moved and we want to track state according to tabpage id. +local to_tabnr = function(tabid) + return tabid > 0 and vim.api.nvim_tabpage_get_number(tabid) or tabid +end + +local get_params_for_cwd = function(state) + local tabid = state.tabid + -- the id is either the tabid for sidebars or the winid for splits + local winid = state.id == tabid and -1 or state.id + + if state.cwd_target then + local target = state.cwd_target.sidebar + if state.current_position == "current" then + target = state.cwd_target.current + end + if target == "window" then + return winid, to_tabnr(tabid) + elseif target == "global" then + return -1, -1 + elseif target == "none" then + return nil, nil + else -- default to tab + return -1, to_tabnr(tabid) + end + else + return winid, to_tabnr(tabid) + end +end + +M.get_cwd = function(state) + local winid, tabnr = get_params_for_cwd(state) + local success, cwd = false, "" + if winid or tabnr then + success, cwd = pcall(vim.fn.getcwd, winid, tabnr) + end + if success then + return cwd + else + success, cwd = pcall(vim.fn.getcwd) + if success then + return cwd + else + return state.path + end + end +end + +M.set_cwd = function(state) + if not state.path then + return + end + + local winid, tabnr = get_params_for_cwd(state) + + if winid == nil and tabnr == nil then + return + end + + local _, cwd = pcall(vim.fn.getcwd, winid, tabnr) + if state.path ~= cwd then + if winid > 0 then + vim.cmd("lcd " .. state.path) + elseif tabnr > 0 then + vim.cmd("tcd " .. state.path) + else + vim.cmd("cd " .. state.path) + end + end +end + +local dispose_state = function(state) + pcall(fs_scan.stop_watchers, state) + pcall(renderer.close, state) + source_data[state.name].state_by_tab[state.id] = nil + source_data[state.name].state_by_win[state.id] = nil + state.disposed = true +end + +M.dispose = function(source_name, tabid) + for i, state in ipairs(all_states) do + if source_name == nil or state.name == source_name then + if not tabid or tabid == state.tabid then + log.trace(state.name, " disposing of tab: ", tabid) + dispose_state(state) + table.remove(all_states, i) + end + end + end +end + +M.dispose_tab = function(tabid) + if not tabid then + error("dispose_tab: tabid cannot be nil") + end + for i, state in ipairs(all_states) do + if tabid == state.tabid then + log.trace(state.name, " disposing of tab: ", tabid, state.name) + dispose_state(state) + table.remove(all_states, i) + end + end +end + +M.dispose_invalid_tabs = function() + -- Iterate in reverse because we are removing items during loop + for i = #all_states, 1, -1 do + local state = all_states[i] + -- if not valid_tabs[state.tabid] then + if not vim.api.nvim_tabpage_is_valid(state.tabid) then + log.trace(state.name, " disposing of tab: ", state.tabid, state.name) + dispose_state(state) + table.remove(all_states, i) + end + end +end + +M.dispose_window = function(winid) + if not winid then + error("dispose_window: winid cannot be nil") + end + for i, state in ipairs(all_states) do + if state.id == winid then + log.trace(state.name, " disposing of window: ", winid, state.name) + dispose_state(state) + table.remove(all_states, i) + end + end +end + +M.float = function(source_name) + local state = M.get_state(source_name) + state.current_position = "float" + local path_to_reveal = M.get_path_to_reveal() + M.navigate(source_name, state.path, path_to_reveal) +end + +---Focus the window, opening it if it is not already open. +---@param source_name string Source name. +---@param path_to_reveal string|nil Node to focus after the items are loaded. +---@param callback function|nil Callback to call after the items are loaded. +M.focus = function(source_name, path_to_reveal, callback) + local state = M.get_state(source_name) + state.current_position = nil + if path_to_reveal then + M.navigate(source_name, state.path, path_to_reveal, callback) + else + if not state.dirty and renderer.window_exists(state) then + vim.api.nvim_set_current_win(state.winid) + else + M.navigate(source_name, state.path, nil, callback) + end + end +end + +---Redraws the tree with updated modified markers without scanning the filesystem again. +M.opened_buffers_changed = function(source_name, args) + if not type(args) == "table" then + error("opened_buffers_changed: args must be a table") + end + if type(args.opened_buffers) == "table" then + M._for_each_state(source_name, function(state) + if utils.tbl_equals(args.opened_buffers, state.opened_buffers) then + -- no changes, no need to redraw + return + end + state.opened_buffers = args.opened_buffers + renderer.redraw(state) + end) + end +end + +---Navigate to the given path. +---@param state_or_source_name string|table The state or source name to navigate. +---@param path string? Path to navigate to. If empty, will navigate to the cwd. +---@param path_to_reveal string? Node to focus after the items are loaded. +---@param callback function? Callback to call after the items are loaded. +---@param async boolean? Whether to load the items asynchronously, may not be respected by all sources. +M.navigate = function(state_or_source_name, path, path_to_reveal, callback, async) + require("neo-tree").ensure_config() + local state, source_name + if type(state_or_source_name) == "string" then + state = M.get_state(state_or_source_name) + source_name = state_or_source_name + elseif type(state_or_source_name) == "table" then + state = state_or_source_name + source_name = state.name + else + log.error("navigate: state_or_source_name must be a string or a table") + end + log.trace("navigate", source_name, path, path_to_reveal) + local mod = get_source_data(source_name).module + if not mod then + mod = require("neo-tree.sources." .. source_name) + end + mod.navigate(state, path, path_to_reveal, callback, async) +end + +---Redraws the tree without scanning the filesystem again. Use this after +-- making changes to the nodes that would affect how their components are +-- rendered. +M.redraw = function(source_name) + M._for_each_state(source_name, function(state) + renderer.redraw(state) + end) +end + +---Refreshes the tree by scanning the filesystem again. +M.refresh = function(source_name, callback) + if type(callback) ~= "function" then + callback = nil + end + local current_tabid = vim.api.nvim_get_current_tabpage() + log.trace(source_name, "refresh") + for i = 1, #all_states, 1 do + local state = all_states[i] + if state.tabid == current_tabid and state.path and renderer.window_exists(state) then + local success, err = pcall(M.navigate, state, state.path, nil, callback) + if not success then + log.error(err) + end + else + state.dirty = true + end + end +end + +M.reveal_current_file = function(source_name, callback, force_cwd) + log.trace("Revealing current file") + local state = M.get_state(source_name) + state.current_position = nil + + -- When events trigger that try to restore the position of the cursor in the tree window, + -- we want them to ignore this "iteration" as the user is trying to explicitly focus a + -- (potentially) different position/node + state.position.is.restorable = false + + require("neo-tree").close_all_except(source_name) + local path = M.get_path_to_reveal() + if not path then + M.focus(source_name) + return + end + local cwd = state.path + if cwd == nil then + cwd = M.get_cwd(state) + end + if force_cwd then + if not utils.is_subpath(cwd, path) then + state.path, _ = utils.split_path(path) + end + elseif not utils.is_subpath(cwd, path) then + cwd, _ = utils.split_path(path) + inputs.confirm("File not in cwd. Change cwd to " .. cwd .. "?", function(response) + if response == true then + state.path = cwd + M.focus(source_name, path, callback) + else + M.focus(source_name, nil, callback) + end + end) + return + end + if path then + if not renderer.focus_node(state, path) then + M.focus(source_name, path, callback) + end + end +end + +M.reveal_in_split = function(source_name, callback) + local state = M.get_state(source_name, nil, vim.api.nvim_get_current_win()) + state.current_position = "current" + local path_to_reveal = M.get_path_to_reveal() + if not path_to_reveal then + M.navigate(state, nil, nil, callback) + return + end + local cwd = state.path + if cwd == nil then + cwd = M.get_cwd(state) + end + if cwd and not utils.is_subpath(cwd, path_to_reveal) then + state.path, _ = utils.split_path(path_to_reveal) + end + M.navigate(state, state.path, path_to_reveal, callback) +end + +---Opens the tree and displays the current path or cwd, without focusing it. +M.show = function(source_name) + local state = M.get_state(source_name) + state.current_position = nil + if not renderer.window_exists(state) then + local current_win = vim.api.nvim_get_current_win() + M.navigate(source_name, state.path, nil, function() + vim.api.nvim_set_current_win(current_win) + end) + end +end + +M.show_in_split = function(source_name, callback) + local state = M.get_state(source_name, nil, vim.api.nvim_get_current_win()) + state.current_position = "current" + M.navigate(state, state.path, nil, callback) +end + +M.validate_source = function(source_name, module) + if source_name == nil then + error("register_source: source_name cannot be nil") + end + if module == nil then + error("register_source: module cannot be nil") + end + if type(module) ~= "table" then + error("register_source: module must be a table") + end + local required_functions = { + "navigate", + "setup", + } + for _, name in ipairs(required_functions) do + if type(module[name]) ~= "function" then + error("Source " .. source_name .. " must have a " .. name .. " function") + end + end +end + +---Configures the plugin, should be called before the plugin is used. +---@param source_name string Name of the source. +---@param config table Configuration table containing merged configuration for the source. +---@param global_config table Global configuration table, shared between all sources. +---@param module table Module containing the source's code. +M.setup = function(source_name, config, global_config, module) + log.debug(source_name, " setup ", config) + M.unsubscribe_all(source_name) + M.set_default_config(source_name, config) + if module == nil then + module = require("neo-tree.sources." .. source_name) + end + local success, err = pcall(M.validate_source, source_name, module) + if success then + success, err = pcall(module.setup, config, global_config) + if success then + get_source_data(source_name).module = module + else + log.error("Source " .. source_name .. " setup failed: " .. err) + end + else + log.error("Source " .. source_name .. " is invalid: " .. err) + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/ui/highlights.lua b/bundle/neo-tree.nvim/lua/neo-tree/ui/highlights.lua new file mode 100644 index 000000000..c1dd646c9 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/ui/highlights.lua @@ -0,0 +1,271 @@ +local log = require("neo-tree.log") +local utils = require("neo-tree.utils") +local vim = vim +local M = {} + +---@type integer +M.ns_id = vim.api.nvim_create_namespace("neo-tree.nvim") + +M.BUFFER_NUMBER = "NeoTreeBufferNumber" +M.CURSOR_LINE = "NeoTreeCursorLine" +M.DIM_TEXT = "NeoTreeDimText" +M.DIRECTORY_ICON = "NeoTreeDirectoryIcon" +M.DIRECTORY_NAME = "NeoTreeDirectoryName" +M.DOTFILE = "NeoTreeDotfile" +M.FADE_TEXT_1 = "NeoTreeFadeText1" +M.FADE_TEXT_2 = "NeoTreeFadeText2" +M.FILE_ICON = "NeoTreeFileIcon" +M.FILE_NAME = "NeoTreeFileName" +M.FILE_NAME_OPENED = "NeoTreeFileNameOpened" +M.FILTER_TERM = "NeoTreeFilterTerm" +M.FLOAT_BORDER = "NeoTreeFloatBorder" +M.FLOAT_NORMAL = "NeoTreeFloatNormal" +M.FLOAT_TITLE = "NeoTreeFloatTitle" +M.GIT_ADDED = "NeoTreeGitAdded" +M.GIT_CONFLICT = "NeoTreeGitConflict" +M.GIT_DELETED = "NeoTreeGitDeleted" +M.GIT_IGNORED = "NeoTreeGitIgnored" +M.GIT_MODIFIED = "NeoTreeGitModified" +M.GIT_RENAMED = "NeoTreeGitRenamed" +M.GIT_STAGED = "NeoTreeGitStaged" +M.GIT_UNTRACKED = "NeoTreeGitUntracked" +M.GIT_UNSTAGED = "NeoTreeGitUnstaged" +M.HIDDEN_BY_NAME = "NeoTreeHiddenByName" +M.INDENT_MARKER = "NeoTreeIndentMarker" +M.MESSAGE = "NeoTreeMessage" +M.MODIFIED = "NeoTreeModified" +M.NORMAL = "NeoTreeNormal" +M.NORMALNC = "NeoTreeNormalNC" +M.SIGNCOLUMN = "NeoTreeSignColumn" +M.STATUS_LINE = "NeoTreeStatusLine" +M.STATUS_LINE_NC = "NeoTreeStatusLineNC" +M.TAB_ACTIVE = "NeoTreeTabActive" +M.TAB_INACTIVE = "NeoTreeTabInactive" +M.TAB_SEPARATOR_ACTIVE = "NeoTreeTabSeparatorActive" +M.TAB_SEPARATOR_INACTIVE = "NeoTreeTabSeparatorInactive" +M.VERTSPLIT = "NeoTreeVertSplit" +M.WINSEPARATOR = "NeoTreeWinSeparator" +M.END_OF_BUFFER = "NeoTreeEndOfBuffer" +M.ROOT_NAME = "NeoTreeRootName" +M.SYMBOLIC_LINK_TARGET = "NeoTreeSymbolicLinkTarget" +M.TITLE_BAR = "NeoTreeTitleBar" +M.INDENT_MARKER = "NeoTreeIndentMarker" +M.EXPANDER = "NeoTreeExpander" +M.WINDOWS_HIDDEN = "NeoTreeWindowsHidden" +M.PREVIEW = "NeoTreePreview" + +local function dec_to_hex(n, chars) + chars = chars or 6 + local hex = string.format("%0" .. chars .. "x", n) + while #hex < chars do + hex = "0" .. hex + end + return hex +end + +---If the given highlight group is not defined, define it. +---@param hl_group_name string The name of the highlight group. +---@param link_to_if_exists table A list of highlight groups to link to, in +--order of priority. The first one that exists will be used. +---@param background string|nil The background color to use, in hex, if the highlight group +--is not defined and it is not linked to another group. +---@param foreground string|nil The foreground color to use, in hex, if the highlight group +--is not defined and it is not linked to another group. +---@gui string|nil The gui to use, if the highlight group is not defined and it is not linked +--to another group. +---@return table table The highlight group values. +M.create_highlight_group = function(hl_group_name, link_to_if_exists, background, foreground, gui) + local success, hl_group = pcall(vim.api.nvim_get_hl_by_name, hl_group_name, true) + if not success or not hl_group.foreground or not hl_group.background then + for _, link_to in ipairs(link_to_if_exists) do + success, hl_group = pcall(vim.api.nvim_get_hl_by_name, link_to, true) + if success then + local new_group_has_settings = background or foreground or gui + local link_to_has_settings = hl_group.foreground or hl_group.background + if link_to_has_settings or not new_group_has_settings then + vim.cmd("highlight default link " .. hl_group_name .. " " .. link_to) + return hl_group + end + end + end + + if type(background) == "number" then + background = dec_to_hex(background) + end + if type(foreground) == "number" then + foreground = dec_to_hex(foreground) + end + + local cmd = "highlight default " .. hl_group_name + if background then + cmd = cmd .. " guibg=#" .. background + end + if foreground then + cmd = cmd .. " guifg=#" .. foreground + else + cmd = cmd .. " guifg=NONE" + end + if gui then + cmd = cmd .. " gui=" .. gui + end + vim.cmd(cmd) + + return { + background = background and tonumber(background, 16) or nil, + foreground = foreground and tonumber(foreground, 16) or nil, + } + end + return hl_group +end + +local faded_highlight_group_cache = {} +M.get_faded_highlight_group = function(hl_group_name, fade_percentage) + if type(hl_group_name) ~= "string" then + error("hl_group_name must be a string") + end + if type(fade_percentage) ~= "number" then + error("hl_group_name must be a number") + end + if fade_percentage < 0 or fade_percentage > 1 then + error("fade_percentage must be between 0 and 1") + end + + local key = hl_group_name .. "_" .. tostring(math.floor(fade_percentage * 100)) + if faded_highlight_group_cache[key] then + return faded_highlight_group_cache[key] + end + + local normal = vim.api.nvim_get_hl_by_name("Normal", true) + if type(normal.foreground) ~= "number" then + if vim.api.nvim_get_option("background") == "dark" then + normal.foreground = 0xffffff + else + normal.foreground = 0x000000 + end + end + if type(normal.background) ~= "number" then + if vim.api.nvim_get_option("background") == "dark" then + normal.background = 0x000000 + else + normal.background = 0xffffff + end + end + local foreground = dec_to_hex(normal.foreground) + local background = dec_to_hex(normal.background) + + local hl_group = vim.api.nvim_get_hl_by_name(hl_group_name, true) + if type(hl_group.foreground) == "number" then + foreground = dec_to_hex(hl_group.foreground) + end + if type(hl_group.background) == "number" then + background = dec_to_hex(hl_group.background) + end + + local gui = {} + if hl_group.bold then + table.insert(gui, "bold") + end + if hl_group.italic then + table.insert(gui, "italic") + end + if hl_group.underline then + table.insert(gui, "underline") + end + if hl_group.undercurl then + table.insert(gui, "undercurl") + end + if #gui > 0 then + gui = table.concat(gui, ",") + else + gui = nil + end + + local f_red = tonumber(foreground:sub(1, 2), 16) + local f_green = tonumber(foreground:sub(3, 4), 16) + local f_blue = tonumber(foreground:sub(5, 6), 16) + + local b_red = tonumber(background:sub(1, 2), 16) + local b_green = tonumber(background:sub(3, 4), 16) + local b_blue = tonumber(background:sub(5, 6), 16) + + local red = (f_red * fade_percentage) + (b_red * (1 - fade_percentage)) + local green = (f_green * fade_percentage) + (b_green * (1 - fade_percentage)) + local blue = (f_blue * fade_percentage) + (b_blue * (1 - fade_percentage)) + + local new_foreground = + string.format("%s%s%s", dec_to_hex(red, 2), dec_to_hex(green, 2), dec_to_hex(blue, 2)) + + M.create_highlight_group(key, {}, hl_group.background, new_foreground, gui) + faded_highlight_group_cache[key] = key + return key +end + +M.setup = function() + -- Reset this here in case of color scheme change + faded_highlight_group_cache = {} + + local normal_hl = M.create_highlight_group(M.NORMAL, { "Normal" }) + local normalnc_hl = M.create_highlight_group(M.NORMALNC, { "NormalNC", M.NORMAL }) + + M.create_highlight_group(M.SIGNCOLUMN, { "SignColumn", M.NORMAL }) + + M.create_highlight_group(M.STATUS_LINE, { "StatusLine" }) + M.create_highlight_group(M.STATUS_LINE_NC, { "StatusLineNC" }) + + M.create_highlight_group(M.VERTSPLIT, { "VertSplit" }) + M.create_highlight_group(M.WINSEPARATOR, { "WinSeparator" }) + + M.create_highlight_group(M.END_OF_BUFFER, { "EndOfBuffer" }) + + local float_border_hl = + M.create_highlight_group(M.FLOAT_BORDER, { "FloatBorder" }, normalnc_hl.background, "444444") + + M.create_highlight_group(M.FLOAT_NORMAL, { "NormalFloat", M.NORMAL }) + + M.create_highlight_group(M.FLOAT_TITLE, {}, float_border_hl.background, normal_hl.foreground) + + local title_fg = normal_hl.background + if title_fg == float_border_hl.foreground then + title_fg = normal_hl.foreground + end + M.create_highlight_group(M.TITLE_BAR, {}, float_border_hl.foreground, title_fg) + + M.create_highlight_group(M.BUFFER_NUMBER, { "SpecialChar" }) + M.create_highlight_group(M.DIM_TEXT, {}, nil, "505050") + M.create_highlight_group(M.MESSAGE, {}, nil, "505050", "italic") + M.create_highlight_group(M.FADE_TEXT_1, {}, nil, "626262") + M.create_highlight_group(M.FADE_TEXT_2, {}, nil, "444444") + M.create_highlight_group(M.DOTFILE, {}, nil, "626262") + M.create_highlight_group(M.HIDDEN_BY_NAME, { M.DOTFILE }, nil, nil) + M.create_highlight_group(M.CURSOR_LINE, { "CursorLine" }, nil, nil, "bold") + M.create_highlight_group(M.DIRECTORY_NAME, { "Directory" }, "NONE", "NONE") + M.create_highlight_group(M.DIRECTORY_ICON, { "Directory" }, nil, "73cef4") + M.create_highlight_group(M.FILE_ICON, { M.DIRECTORY_ICON }) + M.create_highlight_group(M.FILE_NAME, {}, "NONE", "NONE") + M.create_highlight_group(M.FILE_NAME_OPENED, {}, nil, nil, "bold") + M.create_highlight_group(M.SYMBOLIC_LINK_TARGET, { M.FILE_NAME }) + M.create_highlight_group(M.FILTER_TERM, { "SpecialChar", "Normal" }) + M.create_highlight_group(M.ROOT_NAME, {}, nil, nil, "bold,italic") + M.create_highlight_group(M.INDENT_MARKER, { M.DIM_TEXT }) + M.create_highlight_group(M.EXPANDER, { M.DIM_TEXT }) + M.create_highlight_group(M.MODIFIED, {}, nil, "d7d787") + M.create_highlight_group(M.WINDOWS_HIDDEN, { M.DOTFILE }, nil, nil) + M.create_highlight_group(M.PREVIEW, { "Search" }, nil, nil) + + M.create_highlight_group(M.GIT_ADDED, { "GitGutterAdd", "GitSignsAdd" }, nil, "5faf5f") + M.create_highlight_group(M.GIT_DELETED, { "GitGutterDelete", "GitSignsDelete" }, nil, "ff5900") + M.create_highlight_group(M.GIT_MODIFIED, { "GitGutterChange", "GitSignsChange" }, nil, "d7af5f") + local conflict = M.create_highlight_group(M.GIT_CONFLICT, {}, nil, "ff8700", "italic,bold") + M.create_highlight_group(M.GIT_IGNORED, { M.DOTFILE }, nil, nil) + M.create_highlight_group(M.GIT_RENAMED, { M.GIT_MODIFIED }, nil, nil) + M.create_highlight_group(M.GIT_STAGED, { M.GIT_ADDED }, nil, nil) + M.create_highlight_group(M.GIT_UNSTAGED, { M.GIT_CONFLICT }, nil, nil) + M.create_highlight_group(M.GIT_UNTRACKED, {}, nil, conflict.foreground, "italic") + + M.create_highlight_group(M.TAB_ACTIVE, {}, nil, nil, "bold") + M.create_highlight_group(M.TAB_INACTIVE, {}, "141414", "777777") + M.create_highlight_group(M.TAB_SEPARATOR_ACTIVE, {}, nil, "0a0a0a") + M.create_highlight_group(M.TAB_SEPARATOR_INACTIVE, {}, "141414", "101010") +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/ui/inputs.lua b/bundle/neo-tree.nvim/lua/neo-tree/ui/inputs.lua new file mode 100644 index 000000000..b350242c1 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/ui/inputs.lua @@ -0,0 +1,80 @@ +local vim = vim +local Input = require("nui.input") +local popups = require("neo-tree.ui.popups") +local utils = require("neo-tree.utils") + +local M = {} + +local should_use_popup_input = function() + local nt = require("neo-tree") + return utils.get_value(nt.config, "use_popups_for_input", true, false) +end + +M.show_input = function(input, callback) + input:mount() + + input:map("i", "", function() + vim.cmd("stopinsert") + input:unmount() + end, { noremap = true }) + + input:map("i", "", "", { noremap = true }) + + local event = require("nui.utils.autocmd").event + input:on({ event.BufLeave, event.BufDelete }, function() + input:unmount() + if callback then + callback() + end + end, { once = true }) +end + +M.input = function(message, default_value, callback, options, completion) + if should_use_popup_input() then + local popup_options = popups.popup_options(message, 10, options) + + local input = Input(popup_options, { + prompt = " ", + default_value = default_value, + on_submit = callback, + }) + + M.show_input(input) + else + local opts = { + prompt = message .. " ", + default = default_value, + } + if completion then + opts.completion = completion + end + vim.ui.input(opts, callback) + end +end + +M.confirm = function(message, callback) + if should_use_popup_input() then + local popup_options = popups.popup_options(message, 10) + + local input = Input(popup_options, { + prompt = " y/n: ", + on_close = function() + callback(false) + end, + on_submit = function(value) + callback(value == "y" or value == "Y") + end, + }) + + M.show_input(input) + else + local opts = { + prompt = message .. " y/n: ", + } + vim.ui.input(opts, function(value) + callback(value == "y" or value == "Y") + end) + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/ui/popups.lua b/bundle/neo-tree.nvim/lua/neo-tree/ui/popups.lua new file mode 100644 index 000000000..b411aa794 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/ui/popups.lua @@ -0,0 +1,127 @@ +local vim = vim +local Input = require("nui.input") +local NuiText = require("nui.text") +local NuiPopup = require("nui.popup") +local highlights = require("neo-tree.ui.highlights") +local log = require("neo-tree.log") + +local M = {} + +M.popup_options = function(title, min_width, override_options) + local min_width = min_width or 30 + local width = string.len(title) + 2 + + local nt = require("neo-tree") + local popup_border_style = nt.config.popup_border_style + local popup_border_text = NuiText(" " .. title .. " ", highlights.FLOAT_TITLE) + local col = 0 + -- fix popup position when using multigrid + local popup_last_col = vim.api.nvim_win_get_position(0)[2] + width + 2 + if popup_last_col >= vim.o.columns then + col = vim.o.columns - popup_last_col + end + local popup_options = { + ns_id = highlights.ns_id, + relative = "cursor", + position = { + row = 1, + col = col, + }, + size = width, + border = { + text = { + top = popup_border_text, + }, + style = popup_border_style, + highlight = highlights.FLOAT_BORDER, + }, + win_options = { + winhighlight = "Normal:" + .. highlights.FLOAT_NORMAL + .. ",FloatBorder:" + .. highlights.FLOAT_BORDER, + }, + buf_options = { + bufhidden = "delete", + buflisted = false, + filetype = "neo-tree-popup", + }, + } + + if popup_border_style == "NC" then + local blank = NuiText(" ", highlights.TITLE_BAR) + popup_border_text = NuiText(" " .. title .. " ", highlights.TITLE_BAR) + popup_options.border = { + style = { "▕", blank, "▏", "▏", " ", "▔", " ", "▕" }, + highlight = highlights.FLOAT_BORDER, + text = { + top = popup_border_text, + top_align = "left", + }, + } + end + + if override_options then + return vim.tbl_extend("force", popup_options, override_options) + else + return popup_options + end +end + +M.alert = function(title, message, size) + local lines = {} + local max_line_width = title:len() + local add_line = function(line) + if not type(line) == "string" then + line = tostring(line) + end + if line:len() > max_line_width then + max_line_width = line:len() + end + table.insert(lines, line) + end + + if type(message) == "table" then + for _, v in ipairs(message) do + add_line(v) + end + else + add_line(message) + end + + add_line("") + add_line(" Press or to close") + + local win_options = M.popup_options(title, 80) + win_options.zindex = 60 + win_options.size = { + width = max_line_width + 4, + height = #lines + 1, + } + local win = NuiPopup(win_options) + win:mount() + + local success, msg = pcall(vim.api.nvim_buf_set_lines, win.bufnr, 0, 0, false, lines) + if success then + win:map("n", "", function(bufnr) + win:unmount() + end, { noremap = true }) + + win:map("n", "", function(bufnr) + win:unmount() + end, { noremap = true }) + + local event = require("nui.utils.autocmd").event + win:on({ event.BufLeave, event.BufDelete }, function() + win:unmount() + end, { once = true }) + + -- why is this necessary? + vim.api.nvim_set_current_win(win.winid) + else + log.error(msg) + win:unmount() + end +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/ui/renderer.lua b/bundle/neo-tree.nvim/lua/neo-tree/ui/renderer.lua new file mode 100644 index 000000000..4a2aecebe --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/ui/renderer.lua @@ -0,0 +1,1253 @@ +local vim = vim +local NuiLine = require("nui.line") +local NuiTree = require("nui.tree") +local NuiSplit = require("nui.split") +local NuiPopup = require("nui.popup") +local utils = require("neo-tree.utils") +local highlights = require("neo-tree.ui.highlights") +local popups = require("neo-tree.ui.popups") +local events = require("neo-tree.events") +local keymap = require("nui.utils.keymap") +local autocmd = require("nui.utils.autocmd") +local log = require("neo-tree.log") + +local M = { resize_timer_interval = 50 } +local ESC_KEY = vim.api.nvim_replace_termcodes("", true, false, true) +local default_popup_size = { width = 60, height = "80%" } +local draw, create_window, create_tree, render_tree + +local floating_windows = {} +local update_floating_windows = function() + local valid_windows = {} + for _, win in ipairs(floating_windows) do + if M.is_window_valid(win.winid) then + table.insert(valid_windows, win) + end + end + floating_windows = valid_windows +end + +local tabid_to_tabnr = function(tabid) + return vim.api.nvim_tabpage_is_valid(tabid) and vim.api.nvim_tabpage_get_number(tabid) +end + +local cleaned_up = false +---Clean up invalid neotree buffers (e.g after a session restore) +---@param force boolean if true, force cleanup. Otherwise only cleanup once +M.clean_invalid_neotree_buffers = function(force) + if cleaned_up and not force then + return + end + + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + local bufname = vim.fn.bufname(buf) + local is_neotree_buffer = string.match(bufname, "neo%-tree [^ ]+ %[%d+]") + local is_valid_neotree, _ = pcall(vim.api.nvim_buf_get_var, buf, "neo_tree_source") + if is_neotree_buffer and not is_valid_neotree then + vim.api.nvim_buf_delete(buf, { force = true }) + end + end + cleaned_up = true +end + +local resize_monitor_timer = nil +local start_resize_monitor = function() + local interval = M.resize_timer_interval or -1 + if interval < 0 then + return + end + if type(interval) ~= "number" then + log.warn("Invalid resize_timer_interval:", interval) + return + end + if resize_monitor_timer then + return + end + local manager = require("neo-tree.sources.manager") + local check_window_size + local speed_up_loops = 0 + check_window_size = function() + local windows_exist = false + local success, err = pcall(manager._for_each_state, nil, function(state) + if state.win_width and M.tree_is_visible(state) then + windows_exist = true + local current_size = utils.get_inner_win_width(state.winid) + if current_size ~= state.win_width then + log.trace("Window size changed, redrawing tree") + state.win_width = current_size + render_tree(state) + speed_up_loops = 21 -- move to fast timer for the next 1000 ms + end + end + end) + + speed_up_loops = speed_up_loops - 1 + if success then + if windows_exist then + local this_interval = interval + if speed_up_loops > 0 then + this_interval = 50 + else + speed_up_loops = 0 + end + vim.defer_fn(check_window_size, this_interval) + else + log.trace("No windows exist, stopping resize monitor") + end + else + log.debug("Error checking window size: ", err) + vim.defer_fn(check_window_size, math.max(interval * 5, 1000)) + end + end + + vim.defer_fn(check_window_size, interval) +end + +M.close = function(state) + local window_existed = false + if state and state.winid then + if M.window_exists(state) then + local bufnr = vim.api.nvim_win_get_buf(state.winid) + -- if bufnr is different then we expect, then it was taken over by + -- another buffer, so we can't delete it now + if bufnr == state.bufnr then + window_existed = true + if state.current_position == "current" then + -- we are going to hide the buffer instead of closing the window + M.position.save(state) + local new_buf = vim.fn.bufnr("#") + if new_buf < 1 then + new_buf = vim.api.nvim_create_buf(true, false) + end + vim.api.nvim_win_set_buf(state.winid, new_buf) + else + local win_list = vim.api.nvim_tabpage_list_wins(0) + if #win_list > 1 then + local args = { + position = state.current_position, + source = state.name, + winid = state.winid, + tabnr = tabid_to_tabnr(state.tabid), -- for compatibility + tabid = state.tabid, + } + events.fire_event(events.NEO_TREE_WINDOW_BEFORE_CLOSE, args) + -- focus the prior used window if we are closing the currently focused window + local current_winid = vim.api.nvim_get_current_win() + if current_winid == state.winid then + local pwin = require("neo-tree").get_prior_window() + if type(pwin) == "number" and pwin > 0 then + pcall(vim.api.nvim_set_current_win, pwin) + end + end + -- if the window was a float, changing the current win would have closed it already + pcall(vim.api.nvim_win_close, state.winid, true) + events.fire_event(events.NEO_TREE_WINDOW_AFTER_CLOSE, args) + end + end + end + end + state.winid = nil + end + local bufnr = utils.get_value(state, "bufnr", 0, true) + if bufnr > 0 then + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + state.bufnr = nil + end + return window_existed +end + +M.close_floating_window = function(source_name) + local found_windows = {} + for _, win in ipairs(floating_windows) do + if win.source_name == source_name then + table.insert(found_windows, win) + end + end + + local valid_window_was_closed = false + for _, win in ipairs(found_windows) do + if not valid_window_was_closed then + valid_window_was_closed = M.is_window_valid(win.winid) + end + -- regardless of whether the window is valid or not, nui will cleanup + win:unmount() + end + return valid_window_was_closed +end + +M.close_all_floating_windows = function() + while #floating_windows > 0 do + local win = table.remove(floating_windows) + win:unmount() + end +end + +M.get_nui_popup = function(winid) + for _, win in ipairs(floating_windows) do + if win.winid == winid then + return win + end + end +end + +local remove_filtered = function(source_items, filtered_items) + local visible = {} + local hidden = {} + for _, child in ipairs(source_items) do + local fby = child.filtered_by + if type(fby) == "table" and not child.is_reveal_target and not fby.show_anyway then + if not fby.never_show then + if filtered_items.visible or child.is_nested or fby.always_show then + table.insert(visible, child) + else + table.insert(hidden, child) + end + end + else + table.insert(visible, child) + end + end + return visible, hidden +end + +local create_nodes +---Transforms a list of items into a collection of TreeNodes. +---@param source_items table The list of items to transform. The expected +--interface for these items depends on the component renderers configured for +--the given source, but they must contain at least an id field. +---@param state table The current state of the plugin. +---@param level integer Optional. The current level of the tree, defaults to 0. +---@return table A collection of TreeNodes. +create_nodes = function(source_items, state, level) + level = level or 0 + local nodes = {} + local filtered_items = state.filtered_items or {} + local visible, hidden = remove_filtered(source_items, filtered_items) + + if #visible == 0 and level <= 1 and filtered_items.force_visible_in_empty_folder then + source_items = hidden + else + source_items = visible + end + + local show_indent_marker_for_message + local msg = state.renderers.message or {} + if msg[1] and msg[1][1] == "indent" then + show_indent_marker_for_message = msg[1].with_markers + end + + for i, item in ipairs(source_items) do + local is_last_child = i == #source_items + + local nodeData = { + id = item.id, + name = item.name, + type = item.type, + loaded = item.loaded, + filtered_by = item.filtered_by, + extra = item.extra, + is_nested = item.is_nested, + skip_node = item.skip_node, + is_empty_with_hidden_root = item.is_empty_with_hidden_root, + -- TODO: The below properties are not universal and should not be here. + -- Maybe they should be moved to the "extra" field? + is_link = item.is_link, + link_to = item.link_to, + path = item.path, + ext = item.ext, + search_pattern = item.search_pattern, + level = level, + is_last_child = is_last_child, + } + local indent = (state.renderers[item.type] or {}).indent_size or 4 + + local node_children = nil + if item.children ~= nil then + node_children = create_nodes(item.children, state, level + 1) + end + + local node = NuiTree.Node(nodeData, node_children) + if item._is_expanded then + node:expand() + end + table.insert(nodes, node) + end + + if #hidden > 0 then + if source_items == hidden then + local nodeData = { + id = hidden[#hidden].id .. "_hidden_message", + name = "(forced to show " + .. #hidden + .. " hidden " + .. (#hidden > 1 and "items" or "item") + .. ")", + type = "message", + level = level, + is_last_child = show_indent_marker_for_message, + } + local node = NuiTree.Node(nodeData) + table.insert(nodes, node) + elseif filtered_items.show_hidden_count or (#visible == 0 and level <= 1) then + local nodeData = { + id = hidden[#hidden].id .. "_hidden_message", + name = "(" .. #hidden .. " hidden " .. (#hidden > 1 and "items" or "item") .. ")", + type = "message", + level = level, + is_last_child = show_indent_marker_for_message, + } + if #nodes > 0 then + nodes[#nodes].is_last_child = not show_indent_marker_for_message + end + local node = NuiTree.Node(nodeData) + table.insert(nodes, node) + end + end + return nodes +end + +local one_line = function(text) + if type(text) == "string" then + return text:gsub("\n", " ") + else + return text + end +end + +M.render_component = function(component, item, state, remaining_width) + local component_func = state.components[component[1]] + if component_func then + local success, component_data, wanted_width = + pcall(component_func, component, item, state, remaining_width) + if success then + if component_data == nil then + return { {} } + end + if component_data.text then + -- everything else is easier if we make sure this is always the same shape + -- which is an array of { text, highlight } tables + component_data = { component_data } + end + for _, data in ipairs(component_data) do + data.text = one_line(data.text) + end + return component_data, wanted_width + else + local name = component[1] or "[missing_name]" + local msg = string.format("Error rendering component %s: %s", name, component_data) + log.warn(msg) + return { { text = msg, highlight = highlights.NORMAL } } + end + else + local name = component[1] or "[missing_name]" + local msg = "Neo-tree: Component " .. name .. " not found." + log.warn(msg) + return { { text = msg, highlight = highlights.NORMAL } } + end +end + +local prepare_node = function(item, state) + if item.skip_node then + if item.is_empty_with_hidden_root then + local line = NuiLine() + line:append("(empty folder)", highlights.MESSAGE) + return line + else + return nil + end + end + -- pre_render is used to calculate the longest node width + -- without actually rendering the node. + -- We'll try to reuse that work if possible. + local pre_render = state._in_pre_render + if item.line and not pre_render then + local line = item.line + -- Only use it once, we don't want to accidentally use stale data + item.line = nil + if + line + and item.wanted_width + and state.longest_node + and item.wanted_width <= state.longest_node + then + return line + end + end + local line = NuiLine() + + local renderer = state.renderers[item.type] + if not renderer then + line:append(item.type .. ": ", "Comment") + line:append(item.name) + return line + end + + local remaining_cols = state.win_width + if remaining_cols == nil then + if state.winid then + remaining_cols = vim.api.nvim_win_get_width(state.winid) + else + local default_width = utils.resolve_config_option(state, "window.width", 40) + remaining_cols = default_width + end + end + + local wanted_width = 0 + if state.current_position == "current" then + local longest = state.longest_node or 0 + remaining_cols = math.min(remaining_cols, longest + 4) + end + + local should_pad = false + + for _, component in ipairs(renderer) do + local component_data, component_wanted_width = + M.render_component(component, item, state, remaining_cols - (should_pad and 1 or 0)) + local actual_width = 0 + if component_data then + for _, data in ipairs(component_data) do + if data.text then + local padding = "" + if should_pad and #data.text and data.text:sub(1, 1) ~= " " and not data.no_padding then + padding = " " + end + data.text = padding .. data.text + should_pad = data.text:sub(#data.text) ~= " " + + actual_width = actual_width + vim.api.nvim_strwidth(data.text) + line:append(data.text, data.highlight) + remaining_cols = remaining_cols - vim.fn.strchars(data.text) + end + end + end + component_wanted_width = component_wanted_width or actual_width + wanted_width = wanted_width + component_wanted_width + end + + line.wanted_width = wanted_width + if pre_render then + item.line = line + state.longest_node = math.max(state.longest_node, line.wanted_width) + else + item.line = nil + end + + return line +end + +---Sets the cursor at the specified node. +---@param state table The current state of the source. +---@param id string? The id of the node to set the cursor at. +---@return boolean boolean True if the node was found and focused, false +---otherwise. +M.focus_node = function(state, id, do_not_focus_window, relative_movement, bottom_scroll_padding) + if not id and not relative_movement then + log.debug("focus_node called with no id and no relative movement") + return false + end + relative_movement = relative_movement or 0 + bottom_scroll_padding = bottom_scroll_padding or 0 + + local tree = state.tree + if not tree then + log.debug("focus_node called with no tree") + return false + end + local node, linenr = tree:get_node(id) + if not node then + log.debug("focus_node cannot find node with id ", id) + return false + end + id = node:get_id() -- in case nil was passed in for id, meaning current node + + local bufnr = utils.get_value(state, "bufnr", 0, true) + if bufnr == 0 then + log.debug("focus_node: state has no bufnr ", state.bufnr, " / ", state.winid) + return false + end + if not vim.api.nvim_buf_is_valid(bufnr) then + log.debug("focus_node: bufnr is not valid") + return false + end + + if M.window_exists(state) then + if not linenr then + M.expand_to_node(state, node) + node, linenr = tree:get_node(id) + if not linenr then + log.debug("focus_node cannot get linenr for node with id ", id) + return false + end + end + local focus_window = not do_not_focus_window + if focus_window then + vim.api.nvim_set_current_win(state.winid) + end + + -- focus the correct line + linenr = linenr + relative_movement + local col = 0 + if node.indent then + col = string.len(node.indent) + end + local success, err = pcall(vim.api.nvim_win_set_cursor, state.winid, { linenr, col }) + + -- now ensure that the window is scrolled correctly + if success then + local execute_win_command = function(cmd) + if vim.api.nvim_get_current_win() == state.winid then + vim.cmd(cmd) + else + vim.cmd("call win_execute(" .. state.winid .. [[, "]] .. cmd .. [[")]]) + end + end + + -- make sure we are not scrolled down if it can all fit on the screen + local lines = vim.api.nvim_buf_line_count(state.bufnr) + local win_height = vim.api.nvim_win_get_height(state.winid) + local expected_bottom_line = math.min(lines, linenr + 5) + bottom_scroll_padding + if expected_bottom_line > win_height then + execute_win_command("normal! zb") + local top = vim.fn.line("w0", state.winid) + local bottom = vim.fn.line("w$", state.winid) + local offset_top = top + (expected_bottom_line - bottom) + execute_win_command("normal! " .. offset_top .. "zt") + pcall(vim.api.nvim_win_set_cursor, state.winid, { linenr, col }) + elseif win_height > linenr then + execute_win_command("normal! zb") + elseif linenr < (win_height / 2) then + execute_win_command("normal! zz") + end + else + log.debug("Failed to set cursor: " .. err) + end + return success + else + log.debug("focus_node: window does not exist") + return false + end + + return false +end + +M.get_all_visible_nodes = function(tree) + local nodes = {} + + local function process(node) + table.insert(nodes, node) + if node:is_expanded() then + if node:has_children() then + for _, child in ipairs(tree:get_nodes(node:get_id())) do + process(child) + end + end + end + end + + for _, node in ipairs(tree:get_nodes()) do + process(node) + end + return nodes +end + +M.get_expanded_nodes = function(tree, root_node_id) + local node_ids = {} + + local function process(node) + local id = node:get_id() + if node:is_expanded() then + table.insert(node_ids, id) + end + if node:has_children() then + for _, child in ipairs(tree:get_nodes(id)) do + process(child) + end + end + end + + if root_node_id then + local root_node = tree:get_node(root_node_id) + if root_node then + process(root_node) + end + else + for _, node in ipairs(tree:get_nodes()) do + process(node) + end + end + return node_ids +end + +M.collapse_all_nodes = function(tree, root_node_id) + local expanded = M.get_expanded_nodes(tree, root_node_id) + for _, id in ipairs(expanded) do + local node = tree:get_node(id) + if utils.is_expandable(node) then + node:collapse(id) + end + end + -- but make sure the root is expanded + local root = tree:get_nodes()[1] + if root then + root:expand() + end +end + +M.expand_to_node = function(state, node) + if not M.tree_is_visible(state) then + return + end + local tree = state.tree + if type(node) == "string" then + node = tree:get_node(node) + end + local parentId = node:get_parent_id() + while parentId do + local parent = tree:get_node(parentId) + parent:expand() + parentId = parent:get_parent_id() + end + render_tree(state) +end + +---Functions to save and restore the focused node. +M.position = { + save = function(state) + if state.tree and M.window_exists(state) then + local success, node = pcall(state.tree.get_node, state.tree) + if success and node then + _, state.position.node_id = pcall(node.get_id, node) + end + end + local win_state = vim.fn.winsaveview() + state.position.topline = win_state.topline + -- Only need to restore the cursor state once per save, comes + -- into play when some actions fire multiple times per "iteration" + -- within the scope of where we need to perform the restore operation + state.position.is.restorable = true + end, + set = function(state, node_id) + if not type(node_id) == "string" and node_id > "" then + return + end + state.position.node_id = node_id + state.position.is.restorable = true + end, + restore = function(state) + if not state.position.node_id then + log.debug("No node_id to restore to") + return + end + if state.position.is.restorable then + log.debug("Restoring position to node_id: " .. state.position.node_id) + M.focus_node(state, state.position.node_id, true) + else + log.debug("Position is not restorable") + end + if state.position.topline then + vim.fn.winrestview({ topline = state.position.topline }) + end + state.position.is.restorable = false + end, + is = { restorable = true }, +} + +---Redraw the tree without relaoding from the source. +---@param state table State of the tree. +M.redraw = function(state) + if state.tree and M.tree_is_visible(state) then + log.trace("Redrawing tree", state.name, state.id) + render_tree(state) + log.trace(" Redrawing tree done", state.name, state.id) + end +end +---Visit all nodes ina tree recursively and reduce to a single value. +---@param tree table NuiTree +---@param memo any Value that is passed to the accumulator function +---@param func function Accumulator function that is called for each node +---@return any any The final memo value. +M.reduce_nodes = function(tree, memo, func) + if type(func) ~= "function" then + error("func must be a function") + end + local visit + visit = function(node) + func(node, memo) + if node:has_children() then + for _, child in ipairs(tree:get_nodes(node:get_id())) do + visit(child) + end + end + end + for _, node in ipairs(tree:get_nodes()) do + visit(node) + end + return memo +end + +---Visits all nodes in the tree and returns a list of all nodes that match the +---given predicate. +---@param tree table The NuiTree to search. +---@param selector_func function The predicate function, should return true for +---nodes that should be included in the result. +---@return table table A list of nodes that match the predicate. +M.select_nodes = function(tree, selector_func, limit) + if type(selector_func) ~= "function" then + error("selector_func must be a function") + end + local found_nodes = {} + local visit + visit = function(node) + if selector_func(node) then + table.insert(found_nodes, node) + if limit and #found_nodes >= limit then + return + end + end + if node:has_children() then + for _, child in ipairs(tree:get_nodes(node:get_id())) do + visit(child) + end + end + end + for _, node in ipairs(tree:get_nodes()) do + visit(node) + if limit and #found_nodes >= limit then + break + end + end + return found_nodes +end + +M.set_expanded_nodes = function(tree, expanded_nodes) + M.collapse_all_nodes(tree) + log.debug("Setting expanded nodes") + for _, id in ipairs(expanded_nodes or {}) do + local node = tree:get_node(id) + if node ~= nil then + node:expand() + end + end +end + +create_tree = function(state) + state.tree = NuiTree({ + ns_id = highlights.ns_id, + winid = state.winid, + get_node_id = function(node) + return node.id + end, + prepare_node = function(data) + return prepare_node(data, state) + end, + }) +end + +local get_selected_nodes = function(state) + if state.winid ~= vim.api.nvim_get_current_win() then + return nil + end + local start_pos = vim.fn.getpos("'<")[2] + local end_pos = vim.fn.getpos("'>")[2] + if end_pos < start_pos then + -- I'm not sure if this could actually happen, but just in case + start_pos, end_pos = end_pos, start_pos + end + local selected_nodes = {} + while start_pos <= end_pos do + local node = state.tree:get_node(start_pos) + if node then + table.insert(selected_nodes, node) + end + start_pos = start_pos + 1 + end + return selected_nodes +end + +local set_window_mappings = function(state) + local resolved_mappings = {} + local skip_this_mapping = { + ["none"] = true, + ["nop"] = true, + ["noop"] = true, + } + local mappings = utils.get_value(state, "window.mappings", {}, true) + local mapping_options = utils.get_value(state, "window.mapping_options", { noremap = true }, true) + for cmd, func in pairs(mappings) do + local vfunc + local config = {} + if utils.truthy(func) then + if skip_this_mapping[func] then + log.trace("Skipping mapping for %s", cmd) + else + local map_options = vim.deepcopy(mapping_options) + if type(func) == "table" then + for key, value in pairs(func) do + if key ~= "command" and key ~= 1 and key ~= "config" then + map_options[key] = value + end + end + config = func.config or {} + func = func.command or func[1] + end + if type(func) == "string" then + resolved_mappings[cmd] = { text = func } + vfunc = state.commands[func .. "_visual"] + func = state.commands[func] + elseif type(func) == "function" then + resolved_mappings[cmd] = { text = "" } + end + if type(func) == "function" then + resolved_mappings[cmd].handler = function() + state.config = config + func(state) + end + keymap.set(state.bufnr, "n", cmd, resolved_mappings[cmd].handler, map_options) + if type(vfunc) == "function" then + keymap.set(state.bufnr, "v", cmd, function() + vim.api.nvim_feedkeys(ESC_KEY, "i", true) + vim.schedule(function() + local selected_nodes = get_selected_nodes(state) + if utils.truthy(selected_nodes) then + state.config = config + vfunc(state, selected_nodes) + end + end) + end, map_options) + end + else + log.warn("Invalid mapping for ", cmd, ": ", func) + resolved_mappings[cmd] = "" + end + end + end + end + state.resolved_mappings = resolved_mappings +end + +local function create_floating_window(state, win_options, bufname) + local win + state.force_float = nil + -- First get the default options for floating windows. + local sourceTitle = state.name:gsub("^%l", string.upper) + win_options = popups.popup_options("Neo-tree " .. sourceTitle, 40, win_options) + win_options.win_options = nil + win_options.zindex = 40 + + -- Then override with source specific options. + local b = win_options.border + win_options.size = utils.resolve_config_option(state, "window.popup.size", default_popup_size) + win_options.position = utils.resolve_config_option(state, "window.popup.position", "50%") + win_options.border = utils.resolve_config_option(state, "window.popup.border", b) + + win = NuiPopup(win_options) + win:mount() + win.source_name = state.name + win.original_options = state.window + table.insert(floating_windows, win) + + if require("neo-tree").config.close_floats_on_escape_key then + win:map("n", "", function(_) + win:unmount() + end, { noremap = true }) + end + + win:on({ "BufHidden" }, function() + vim.schedule(function() + win:unmount() + end) + end, { once = true }) + state.winid = win.winid + state.bufnr = win.bufnr + log.debug("Created floating window with winid: ", win.winid, " and bufnr: ", win.bufnr) + vim.api.nvim_buf_set_name(state.bufnr, bufname) + + -- why is this necessary? + vim.api.nvim_set_current_win(win.winid) + return win +end + +create_window = function(state) + local default_position = utils.resolve_config_option(state, "window.position", "left") + local relative = utils.resolve_config_option(state, "window.relative", "editor") + state.current_position = state.current_position or default_position + + local bufname = string.format("neo-tree %s [%s]", state.name, state.id) + local size_opt, default_size + if state.current_position == "top" or state.current_position == "bottom" then + size_opt, default_size = "window.height", "15" + else + size_opt, default_size = "window.width", "40" + end + local win_options = { + ns_id = highlights.ns_id, + size = utils.resolve_config_option(state, size_opt, default_size), + position = state.current_position, + relative = relative, + buf_options = { + buftype = "nofile", + modifiable = false, + swapfile = false, + filetype = "neo-tree", + undolevels = -1, + }, + win_options = { + colorcolumn = "", + signcolumn = "no", + }, + } + + local event_args = { + position = state.current_position, + source = state.name, + tabnr = tabid_to_tabnr(state.tabid), -- for compatibility + tabid = state.tabid, + } + events.fire_event(events.NEO_TREE_WINDOW_BEFORE_OPEN, event_args) + + local win + if state.current_position == "float" then + win = create_floating_window(state, win_options, bufname) + elseif state.current_position == "current" then + -- state.id is always the window id or tabnr that this state was created for + -- in the case of a position = current state object, it will be the window id + local winid = state.id + if not vim.api.nvim_win_is_valid(winid) then + log.warn("Window ", winid, " is no longer valid!") + return + end + local bufnr = vim.fn.bufnr(bufname) + if bufnr < 1 then + bufnr = vim.api.nvim_create_buf(false, false) + vim.api.nvim_buf_set_name(bufnr, bufname) + end + state.winid = winid + state.bufnr = bufnr + vim.api.nvim_buf_set_option(bufnr, "buftype", "nofile") + vim.api.nvim_buf_set_option(bufnr, "swapfile", false) + vim.api.nvim_buf_set_option(bufnr, "filetype", "neo-tree") + vim.api.nvim_buf_set_option(bufnr, "modifiable", false) + vim.api.nvim_buf_set_option(bufnr, "undolevels", -1) + vim.api.nvim_win_set_buf(winid, bufnr) + else + win = NuiSplit(win_options) + win:mount() + state.winid = win.winid + state.bufnr = win.bufnr + vim.api.nvim_buf_set_name(state.bufnr, bufname) + end + event_args.winid = state.winid + events.fire_event(events.NEO_TREE_WINDOW_AFTER_OPEN, event_args) + + if type(state.bufnr) == "number" then + vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_source", state.name) + vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_tabnr", tabid_to_tabnr(state.tabid)) + vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_tabid", state.tabid) + vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_position", state.current_position) + vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_winid", state.winid) + end + + if win == nil then + autocmd.buf.define(state.bufnr, "WinLeave", function() + M.position.save(state) + end) + else + -- Used to track the position of the cursor within the tree as it gains and loses focus + -- + -- Note `WinEnter` is often too early to restore the cursor position so we do not set + -- that up here, and instead trigger those events manually after drawing the tree (not + -- to mention that it would be too late to register `WinEnter` here for the first + -- iteration of that event on the tree window) + win:on({ "WinLeave" }, function() + M.position.save(state) + end) + + win:on({ "BufDelete" }, function() + win:unmount() + end, { once = true }) + end + + set_window_mappings(state) + return win +end + +M.update_floating_window_layouts = function() + update_floating_windows() + for _, win in ipairs(floating_windows) do + local opt = { + relative = "win", + } + opt.size = utils.resolve_config_option(win.original_options, "popup.size", default_popup_size) + opt.position = utils.resolve_config_option(win.original_options, "popup.position", "50%") + win:update_layout(opt) + end +end + +---Determines is the givin winid is valid and the window still exists. +---@param winid any +---@return boolean +M.is_window_valid = function(winid) + if winid == nil then + return false + end + if type(winid) == "number" and winid > 0 then + return vim.api.nvim_win_is_valid(winid) + else + return false + end +end + +---Determines if the window exists and is valid. +---@param state table The current state of the plugin. +---@return boolean True if the window exists and is valid, false otherwise. +M.window_exists = function(state) + local window_exists + local winid = utils.get_value(state, "winid", 0, true) + local bufnr = utils.get_value(state, "bufnr", 0, true) + local default_position = utils.get_value(state, "window.position", "left", true) + local position = state.current_position or default_position + + if winid == 0 then + window_exists = false + elseif position == "current" then + window_exists = vim.api.nvim_win_is_valid(winid) + and vim.api.nvim_buf_is_valid(bufnr) + and vim.api.nvim_win_get_buf(winid) == bufnr + else + local isvalid = M.is_window_valid(winid) + window_exists = isvalid and (vim.api.nvim_win_get_number(winid) > 0) + if not window_exists then + state.winid = nil + if bufnr > 0 and vim.api.nvim_buf_is_valid(bufnr) then + state.bufnr = nil + local success, err = pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) + if not success and err:match("E523") then + vim.schedule_wrap(function() + vim.api.nvim_buf_delete(bufnr, { force = true }) + end)() + end + end + end + end + return window_exists +end + +---Determines if a specific tree is open. +---@param state table The current state of the plugin. +---@return boolean +M.tree_is_visible = function(state) + return M.window_exists(state) and vim.api.nvim_win_get_buf(state.winid) == state.bufnr +end + +---Renders the given tree and expands window width if needed +--@param state table The state containing tree to render. Almost same as state.tree:render() +render_tree = function(state) + local should_auto_expand = state.window.auto_expand_width and state.current_position ~= "float" + local should_pre_render = should_auto_expand or state.current_position == "current" + if should_pre_render and M.tree_is_visible(state) then + log.trace("pre-rendering tree") + state._in_pre_render = true + state.tree:render() + state._in_pre_render = false + state.window.last_user_width = vim.api.nvim_win_get_width(state.winid) + if should_auto_expand and state.longest_node > state.window.last_user_width then + log.trace(string.format("auto_expand_width: on. Expanding width to %s.", state.longest_node)) + vim.api.nvim_win_set_width(state.winid, state.longest_node) + state.win_width = state.longest_node + end + end + if M.tree_is_visible(state) then + state.tree:render() + end +end + +---Draws the given nodes on the screen. +--@param nodes table The nodes to draw. +--@param state table The current state of the source. +draw = function(nodes, state, parent_id) + -- If we are going to redraw, preserve the current set of expanded nodes. + local expanded_nodes = {} + if parent_id == nil and state.tree ~= nil then + if state.force_open_folders then + log.trace("Force open folders") + state.force_open_folders = nil + else + log.trace("Preserving expanded nodes") + expanded_nodes = M.get_expanded_nodes(state.tree) + end + end + if state.default_expanded_nodes then + for _, id in ipairs(state.default_expanded_nodes) do + table.insert(expanded_nodes, id) + end + end + + -- Create the tree if it doesn't exist. + if not parent_id and not M.window_exists(state) then + create_window(state) + create_tree(state) + end + + -- draw the given nodes + local success, msg = pcall(state.tree.set_nodes, state.tree, nodes, parent_id) + if not success then + log.error("Error setting nodes: ", msg) + log.error(vim.inspect(state.tree:get_nodes())) + end + if parent_id ~= nil then + -- this is a dynamic fetch of children that were not previously loaded + local node = state.tree:get_node(parent_id) + node.loaded = true + node:expand() + else + M.set_expanded_nodes(state.tree, expanded_nodes) + end + + -- This is to ensure that containers are always the right size + state.win_width = utils.get_inner_win_width(state.winid) + start_resize_monitor() + + render_tree(state) + + -- draw winbar / statusbar + require("neo-tree.ui.selector").set_source_selector(state) + + -- Restore the cursor position/focused node in the tree based on the state + -- when it was last closed + M.position.restore(state) +end + +local function group_empty_dirs(node) + if node.children == nil then + return node + end + + local first_child = node.children[1] + if #node.children == 1 and first_child.type == "directory" then + -- this is the only path that changes the tree + -- at each step where we discover an empty directory, merge it's name with the parent + -- then skip over it + first_child.name = node.name .. utils.path_separator .. first_child.name + return group_empty_dirs(first_child) + else + for i, child in ipairs(node.children) do + node.children[i] = group_empty_dirs(child) + end + return node + end +end + +---Shows the given items as a tree. +--@param sourceItems table The list of items to transform. +--@param state table The current state of the plugin. +--@param parentId string Optional. The id of the parent node to display these nodes +--at; defaults to nil. +M.show_nodes = function(sourceItems, state, parentId, callback) + --local id = string.format("show_nodes %s:%s [%s]", state.name, state.force_float, state.tabid) + --utils.debounce(id, function() + events.fire_event(events.BEFORE_RENDER, state) + state.longest_width_exact = 0 + local parent + local level = 0 + if parentId ~= nil then + local success + success, parent = pcall(state.tree.get_node, state.tree, parentId) + if success and parent then + level = parent:get_depth() + end + state.longest_node = state.longest_node or 0 + else + state.longest_node = 0 + end + + local config = require("neo-tree").config + if config.hide_root_node then + if not parentId then + sourceItems[1].skip_node = true + if not (sourceItems[1].children and #sourceItems[1].children > 0) then + sourceItems[1].is_empty_with_hidden_root = true + end + end + if not config.retain_hidden_root_indent then + level = level - 1 + end + end + + if config.add_blank_line_at_top and not parentId then + table.insert(sourceItems, 1, { + type = "message", + name = "", + path = "", + id = "blank_line_at_top", + }) + end + + if state.group_empty_dirs then + if parent then + local scan_mode = require("neo-tree").config.filesystem.scan_mode + if scan_mode == "deep" then + for i, item in ipairs(sourceItems) do + sourceItems[i] = group_empty_dirs(item) + end + else + -- this is a lazy load of a single sub folder + group_empty_dirs(sourceItems) + if #sourceItems == 1 and sourceItems[1].type == "directory" then + -- This folder needs to be grouped. + -- The goal is to just update the existing node in place. + -- To avoid digging into private internals of Nui, we will just export the entire level and replace + -- the one node. This keeps it in the right order, because nui doesn't have methods to replace something + -- in place. + -- We can't just mutate the existing node because we have to change it's id which would break Nui's + -- internal state. + local item = sourceItems[1] + parentId = parent:get_parent_id() + local siblings = state.tree:get_nodes(parentId) + for i, node in pairs(siblings) do + if node.id == parent.id then + item.name = parent.name .. utils.path_separator .. item.name + item.level = level - 1 + item.is_loaded = utils.truthy(item.children) + siblings[i] = NuiTree.Node(item, item.children) + break + end + end + sourceItems = nil -- this is a signal to skip the rest of the processing + state.tree:set_nodes(siblings, parentId) + end + end + else + -- if we are rendering a whole tree, just group the children because we don'the + -- want to change the root nodes + for _, item in ipairs(sourceItems) do + if item.children ~= nil then + for i, child in ipairs(item.children) do + item.children[i] = group_empty_dirs(child) + end + end + end + end + end + + if sourceItems then + -- normal path + local nodes = create_nodes(sourceItems, state, level) + draw(nodes, state, parentId) + else + -- this was a force grouping of a lazy loaded folder + state.win_width = utils.get_inner_win_width(state.winid) + render_tree(state) + end + + vim.schedule(function() + events.fire_event(events.AFTER_RENDER, state) + end) + if type(callback) == "function" then + vim.schedule(callback) + end + --end, 100) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/ui/selector.lua b/bundle/neo-tree.nvim/lua/neo-tree/ui/selector.lua new file mode 100644 index 000000000..44a8e1770 --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/ui/selector.lua @@ -0,0 +1,432 @@ +local vim = vim +local utils = require("neo-tree.utils") +local log = require("neo-tree.log") +local manager = require("neo-tree.sources.manager") + +local M = {} + +---calc_click_id_from_source: +-- Calculates click_id that stores information of the source and window id +-- DANGER: Do not change this function unless you know what you are doing +---@param winid integer: window id of the window source_selector is placed +---@param source_index integer: index of the source +---@return integer +local calc_click_id_from_source = function(winid, source_index) + local base_number = #require("neo-tree").config.source_selector.sources + 1 + return base_number * winid + source_index +end + +---calc_source_from_click_id: +-- Calculates source index and window id from click_id. Paired with `M.calc_click_id_from_source` +-- DANGER: Do not change this function unless you know what you are doing +---@param click_id integer: click_id +---@return integer, integer +local calc_source_from_click_id = function(click_id) + local base_number = #require("neo-tree").config.source_selector.sources + 1 + return math.floor(click_id / base_number), click_id % base_number +end +---sep_tbl: +-- Returns table expression of separator. +-- Converts to table expression if sep is string. +---@param sep string | table: +---@return table: `{ left = .., right = .., override = .. }` +local sep_tbl = function(sep) + if type(sep) == "nil" then + return {} + elseif type(sep) ~= "table" then + return { left = sep, right = sep, override = "active" } + end + return sep +end + +-- Function below provided by @akinsho +-- https://github.com/nvim-neo-tree/neo-tree.nvim/pull/427#discussion_r924947766 + +-- truncate a string based on number of display columns/cells it occupies +-- so that multibyte characters are not broken up mid-character +---@param str string +---@param col_limit number +---@return string +local function truncate_by_cell(str, col_limit) + local api = vim.api + local fn = vim.fn + if str and str:len() == api.nvim_strwidth(str) then + return fn.strcharpart(str, 0, col_limit) + end + local short = fn.strcharpart(str, 0, col_limit) + if api.nvim_strwidth(short) > col_limit then + while api.nvim_strwidth(short) > col_limit do + short = fn.strcharpart(short, 0, fn.strchars(short) - 1) + end + end + return short +end + +---get_separators +-- Returns information about separator on each tab. +---@param source_index integer: index of source +---@param active_index integer: index of active source. used to check if source is active and when `override = "active"` +---@param force_ignore_left boolean: overwrites calculated results with "" if set to true +---@param force_ignore_right boolean: overwrites calculated results with "" if set to true +---@return table: something like `{ left = "|", right = "|" }` +local get_separators = function(source_index, active_index, force_ignore_left, force_ignore_right) + local config = require("neo-tree").config + local is_active = source_index == active_index + local sep = sep_tbl(config.source_selector.separator) + if is_active then + sep = vim.tbl_deep_extend("force", sep, sep_tbl(config.source_selector.separator_active)) + end + local show_left = sep.override == "left" + or (sep.override == "active" and source_index <= active_index) + or sep.override == nil + local show_right = sep.override == "right" + or (sep.override == "active" and source_index >= active_index) + or sep.override == nil + return { + left = (show_left and not force_ignore_left) and sep.left or "", + right = (show_right and not force_ignore_right) and sep.right or "", + } +end + +---get_selector_tab_info: +-- Returns information to create a tab +---@param source_name string: name of source. should be same as names in `config.sources` +---@param source_index integer: index of source_name +---@param is_active boolean: whether this source is currently focused +---@param separator table: `{ left = .., right = .. }`: output from `get_separators()` +---@return table (see code): Note: `length`: length of whole tab (including seps), `text_length`: length of tab excluding seps +local get_selector_tab_info = function(source_name, source_index, is_active, separator) + local config = require("neo-tree").config + local separator_config = utils.resolve_config_option(config, "source_selector", nil) + if separator_config == nil then + log.warn("Cannot find source_selector config. `get_selector` abort.") + return {} + end + local source_config = config[source_name] or {} + local get_strlen = vim.api.nvim_strwidth + local text = separator_config.sources[source_index].display_name or source_config.display_name or source_name + local text_length = get_strlen(text) + if separator_config.tabs_min_width ~= nil and text_length < separator_config.tabs_min_width then + text = M.text_layout(text, separator_config.content_layout, separator_config.tabs_min_width) + text_length = separator_config.tabs_min_width + end + if separator_config.tabs_max_width ~= nil and text_length > separator_config.tabs_max_width then + text = M.text_layout(text, separator_config.content_layout, separator_config.tabs_max_width) + text_length = separator_config.tabs_max_width + end + local tab_hl = is_active and separator_config.highlight_tab_active + or separator_config.highlight_tab + local sep_hl = is_active and separator_config.highlight_separator_active + or separator_config.highlight_separator + return { + index = source_index, + is_active = is_active, + left = separator.left, + right = separator.right, + text = text, + tab_hl = tab_hl, + sep_hl = sep_hl, + length = text_length + get_strlen(separator.left) + get_strlen(separator.right), + text_length = text_length, + } +end + +---text_with_hl: +-- Returns text with highlight syntax for winbar / statusline +---@param text string: text to highlight +---@param tab_hl string | nil: if nil, does nothing +---@return string: e.g. "%#HiName#text" +local text_with_hl = function(text, tab_hl) + if tab_hl == nil then + return text + end + return string.format("%%#%s#%s", tab_hl, text) +end + +---add_padding: +-- Use for creating padding with highlight +---@param padding_legth number: number of padding. if float, value is rounded with `math.floor` +---@param padchar string | nil: if nil, " " (space) is used +---@return string +local add_padding = function(padding_legth, padchar) + if padchar == nil then + padchar = " " + end + return string.rep(padchar, math.floor(padding_legth)) +end + +---text_layout: +-- Add padding to fill `output_width`. +-- If `output_width` is less than `text_length`, text is truncated to fit `output_width`. +---@param text string: +---@param content_layout string: `"start", "center", "end"`: see `config.source_selector.tabs_layout` for more details +---@param output_width integer: exact `strdisplaywidth` of the output string +---@param trunc_char string | nil: Character used to indicate truncation. If nil, "…" (ellipsis) is used. +---@return string +local text_layout = function(text, content_layout, output_width, trunc_char) + if output_width < 1 then + return "" + end + local text_length = vim.fn.strdisplaywidth(text) + local pad_length = output_width - text_length + local left_pad, right_pad = 0, 0 + if pad_length < 0 then + if output_width < 4 then + return truncate_by_cell(text, output_width) + else + return truncate_by_cell(text, output_width - 1) .. trunc_char + end + elseif content_layout == "start" then + left_pad, right_pad = 0, pad_length + elseif content_layout == "end" then + left_pad, right_pad = pad_length, 0 + elseif content_layout == "center" then + left_pad, right_pad = pad_length / 2, math.ceil(pad_length / 2) + end + return add_padding(left_pad) .. text .. add_padding(right_pad) +end + +---render_tab: +-- Renders string to express one tab for winbar / statusline. +---@param left_sep string: left separator +---@param right_sep string: right separator +---@param sep_hl string: highlight of separators +---@param text string: text, mostly name of source in this case +---@param tab_hl string: highlight of text +---@param click_id integer: id passed to `___neotree_selector_click`, should be calculated with `M.calc_click_id_from_source` +---@return string: complete string to render one tab +local render_tab = function(left_sep, right_sep, sep_hl, text, tab_hl, click_id) + local res = "%" .. click_id .. "@v:lua.___neotree_selector_click@" + if left_sep ~= nil then + res = res .. text_with_hl(left_sep, sep_hl) + end + res = res .. text_with_hl(text, tab_hl) + if right_sep ~= nil then + res = res .. text_with_hl(right_sep, sep_hl) + end + return res +end + +M.get_scrolled_off_node_text = function(state) + if state == nil then + state = require("neo-tree.sources.manager").get_state_for_window() + if state == nil then + return + end + end + local win_top_line = vim.fn.line("w0") + if win_top_line == nil or win_top_line == 1 then + return + end + local node = state.tree:get_node(win_top_line) + return "  " .. vim.fn.fnamemodify(node.path, ":~:h") +end + +M.get = function() + local state = require("neo-tree.sources.manager").get_state_for_window() + if state == nil then + return + else + local config = require("neo-tree").config + local scrolled_off = + utils.resolve_config_option(config, "source_selector.show_scrolled_off_parent_node", false) + if scrolled_off then + local node_text = M.get_scrolled_off_node_text(state) + if node_text ~= nil then + return node_text + end + end + return M.get_selector(state, vim.api.nvim_win_get_width(0)) + end +end + +---get_selector: +-- Does everything to generate the string for source_selector in winbar / statusline. +---@param state table: +---@param width integer: width of the entire window where the source_selector is displayed +---@return string | nil +M.get_selector = function(state, width) + local config = require("neo-tree").config + if config == nil then + log.warn("Cannot find config. `get_selector` abort.") + return nil + end + local winid = state.winid or vim.api.nvim_get_current_win() + + -- load padding from config + local padding = config.source_selector.padding + if type(padding) == "number" then + padding = { left = padding, right = padding } + end + width = math.floor(width - padding.left - padding.right) + + -- generate information of each tab (look `get_selector_tab_info` for type hint) + local tabs = {} + local sources = config.source_selector.sources + local active_index = #sources + local length_sum, length_active, length_separators = 0, 0, 0 + for i, source_info in ipairs(sources) do + local is_active = source_info.source == state.name + if is_active then + active_index = i + end + local separator = get_separators( + i, + active_index, + config.source_selector.show_separator_on_edge == false and i == 1, + config.source_selector.show_separator_on_edge == false and i == #sources + ) + local element = get_selector_tab_info(source_info.source, i, is_active, separator) + length_sum = length_sum + element.length + length_separators = length_separators + element.length - element.text_length + if is_active then + length_active = element.length + end + table.insert(tabs, element) + end + + -- start creating string to display + local tabs_layout = config.source_selector.tabs_layout + local content_layout = config.source_selector.content_layout or "center" + local hl_background = config.source_selector.highlight_background + local trunc_char = config.source_selector.truncation_character or "…" + local remaining_width = width - length_separators + local return_string = text_with_hl(add_padding(padding.left), hl_background) + if width < length_sum and config.source_selector.text_trunc_to_fit then -- not enough width + local each_width = math.floor(remaining_width / #tabs) + local remaining = remaining_width % each_width + tabs_layout = "start" + length_sum = width + for _, tab in ipairs(tabs) do + tab.text = text_layout( -- truncate text and pass it to "start" + tab.text, + "center", + each_width + (tab.is_active and remaining or 0), + trunc_char + ) + end + end + if tabs_layout == "active" then + local active_tab_length = width - length_sum + length_active + for _, tab in ipairs(tabs) do + return_string = return_string + .. render_tab( + tab.left, + tab.right, + tab.sep_hl, + text_layout( + tab.text, + tab.is_active and content_layout or nil, + active_tab_length, + trunc_char + ), + tab.tab_hl, + calc_click_id_from_source(winid, tab.index) + ) + .. text_with_hl("", hl_background) + end + elseif tabs_layout == "equal" then + for _, tab in ipairs(tabs) do + return_string = return_string + .. render_tab( + tab.left, + tab.right, + tab.sep_hl, + text_layout(tab.text, content_layout, math.floor(remaining_width / #tabs), trunc_char), + tab.tab_hl, + calc_click_id_from_source(winid, tab.index) + ) + .. text_with_hl("", hl_background) + end + else -- config.source_selector.tab_labels == "start", "end", "center" + -- calculate padding based on tabs_layout + local pad_length = width - length_sum + local left_pad, right_pad = 0, 0 + if pad_length > 0 then + if tabs_layout == "start" then + left_pad, right_pad = 0, pad_length + elseif tabs_layout == "end" then + left_pad, right_pad = pad_length, 0 + elseif tabs_layout == "center" then + left_pad, right_pad = pad_length / 2, math.ceil(pad_length / 2) + end + end + + for i, tab in ipairs(tabs) do + if width == 0 then + break + end + + -- only render trunc_char if there is no space for the tab + local sep_length = tab.length - tab.text_length + if width <= sep_length + 1 then + return_string = return_string + .. text_with_hl(trunc_char .. add_padding(width - 1), hl_background) + width = 0 + break + end + + -- tab_length should not exceed width + local tab_length = width < tab.length and width or tab.length + width = width - tab_length + + -- add padding for first and last tab + local tab_text = tab.text + if i == 1 then + tab_text = add_padding(left_pad) .. tab_text + tab_length = tab_length + left_pad + end + if i == #tabs then + tab_text = tab_text .. add_padding(right_pad) + tab_length = tab_length + right_pad + end + + return_string = return_string + .. render_tab( + tab.left, + tab.right, + tab.sep_hl, + text_layout(tab_text, tabs_layout, tab_length - sep_length, trunc_char), + tab.tab_hl, + calc_click_id_from_source(winid, tab.index) + ) + end + end + return return_string .. "%<%0@v:lua.___neotree_selector_click@" +end + +---set_source_selector: +-- (public): Directly set source_selector to current window's winbar / statusline +---@param state table: state +---@return nil +M.set_source_selector = function(state) + local sel_config = utils.resolve_config_option(require("neo-tree").config, "source_selector", {}) + if sel_config and sel_config.winbar then + vim.wo[state.winid].winbar = "%{%v:lua.require'neo-tree.ui.selector'.get()%}" + end + if sel_config and sel_config.statusline then + vim.wo[state.winid].statusline = "%{%v:lua.require'neo-tree.ui.selector'.get()%}" + end +end + +-- @v:lua@ in the tabline only supports global functions, so this is +-- the only way to add click handlers without autoloaded vimscript functions +_G.___neotree_selector_click = function(id, _, _, _) + if id < 1 then + return + end + local sources = require("neo-tree").config.source_selector.sources + local winid, source_index = calc_source_from_click_id(id) + local state = manager.get_state_for_window(winid) + if state == nil then + log.warn("state not found for window ", winid, "; ignoring click") + return + end + require("neo-tree.command").execute({ + source = sources[source_index].source, + position = state.current_position, + action = "focus", + }) +end + +return M diff --git a/bundle/neo-tree.nvim/lua/neo-tree/utils.lua b/bundle/neo-tree.nvim/lua/neo-tree/utils.lua new file mode 100644 index 000000000..443b0bb2a --- /dev/null +++ b/bundle/neo-tree.nvim/lua/neo-tree/utils.lua @@ -0,0 +1,1088 @@ +local vim = vim +local log = require("neo-tree.log") +local bit = require("bit") +local ffi = require("ffi") + +local FILE_ATTRIBUTE_HIDDEN = 0x2 + +ffi.cdef([[ +int GetFileAttributesA(const char *path); +]]) + +-- Backwards compatibility +table.pack = table.pack or function(...) + return { n = select("#", ...), ... } +end +table.unpack = table.unpack or unpack + +local M = {} + +local diag_severity_to_string = function(severity) + if severity == vim.diagnostic.severity.ERROR then + return "Error" + elseif severity == vim.diagnostic.severity.WARN then + return "Warn" + elseif severity == vim.diagnostic.severity.INFO then + return "Info" + elseif severity == vim.diagnostic.severity.HINT then + return "Hint" + else + return nil + end +end + +local tracked_functions = {} +M.debounce_strategy = { + CALL_FIRST_AND_LAST = 0, + CALL_LAST_ONLY = 1, +} + +M.debounce_action = { + START_NORMAL = 0, + START_ASYNC_JOB = 1, + COMPLETE_ASYNC_JOB = 2, +} + +local defer_function +-- Part of debounce. Moved out of the function to eliminate memory leaks. +defer_function = function(id, frequency_in_ms, strategy, action) + tracked_functions[id].in_debounce_period = true + vim.defer_fn(function() + local current_data = tracked_functions[id] + if not current_data then + return + end + if current_data.async_in_progress then + defer_function(id, frequency_in_ms, strategy, action) + return + end + local _fn = current_data.fn + current_data.fn = nil + current_data.in_debounce_period = false + if _fn ~= nil then + M.debounce(id, _fn, frequency_in_ms, strategy, action) + end + end, frequency_in_ms) +end + +---Call fn, but not more than once every x milliseconds. +---@param id string Identifier for the debounce group, such as the function name. +---@param fn function Function to be executed. +---@param frequency_in_ms number Miniumum amount of time between invocations of fn. +---@param strategy number The debounce_strategy to use, determines which calls to fn are not dropped. +M.debounce = function(id, fn, frequency_in_ms, strategy, action) + local fn_data = tracked_functions[id] + + if fn_data == nil then + if action == M.debounce_action.COMPLETE_ASYNC_JOB then + -- original call complete and no further requests have been made + return + end + -- first call for this id + fn_data = { + id = id, + in_debounce_period = false, + fn = fn, + frequency_in_ms = frequency_in_ms, + } + tracked_functions[id] = fn_data + if strategy == M.debounce_strategy.CALL_LAST_ONLY then + defer_function(id, frequency_in_ms, strategy, action) + return + end + else + fn_data.fn = fn + fn_data.frequency_in_ms = frequency_in_ms + if action == M.debounce_action.COMPLETE_ASYNC_JOB then + fn_data.async_in_progress = false + return + elseif fn_data.async_in_progress then + defer_function(id, frequency_in_ms, strategy, action) + return + end + end + + if fn_data.in_debounce_period then + -- This id was called recently and can't be executed again yet. + -- Last one in wins. + return + end + + -- Run the requested function normally. + -- Use a pcall to ensure the debounce period is still respected even if + -- this call throws an error. + local success, result = true, nil + fn_data.in_debounce_period = true + if type(fn) == "function" then + success, result = pcall(fn) + end + fn_data.fn = nil + fn = nil + + if not success then + log.error("debounce ", id, " error: ", result) + elseif result and action == M.debounce_action.START_ASYNC_JOB then + -- This can't fire again until the COMPLETE_ASYNC_JOB signal is sent. + fn_data.async_in_progress = true + end + + if strategy == M.debounce_strategy.CALL_LAST_ONLY then + if fn_data.async_in_progress then + defer_function(id, frequency_in_ms, strategy, action) + else + -- We are done with this debounce + tracked_functions[id] = nil + end + else + -- Now schedule the next earliest execution. + -- If there are no calls to run the same function between now + -- and when this deferred executes, nothing will happen. + -- If there are several calls, only the last one in will run. + strategy = M.debounce_strategy.CALL_LAST_ONLY + defer_function(id, frequency_in_ms, strategy, action) + end +end + +--- Returns true if the contents of two tables are equal. +M.tbl_equals = function(table1, table2) + -- same object + if table1 == table2 then + return true + end + + -- not the same type + if type(table1) ~= "table" or type(table2) ~= "table" then + return false + end + + -- If tables are lists, check if they have the same values in the same order + if #table1 ~= #table2 then + return false + end + for i, v in ipairs(table1) do + if table2[i] ~= v then + return false + end + end + + -- Check if the tables have the same key/value pairs + for k, v in pairs(table1) do + if table2[k] ~= v then + return false + end + end + for k, v in pairs(table2) do + if table1[k] ~= v then + return false + end + end + + -- No differences found, tables are equal + return true +end + +M.execute_command = function(cmd) + local result = vim.fn.systemlist(cmd) + + -- An empty result is ok + if vim.v.shell_error ~= 0 or (#result > 0 and vim.startswith(result[1], "fatal:")) then + return false, {} + else + return true, result + end +end + +M.find_buffer_by_name = function(name) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + local buf_name = vim.api.nvim_buf_get_name(buf) + if buf_name == name then + return buf + end + end + return -1 +end + +---Gets diagnostic severity counts for all files +---@return table table { file_path = { Error = int, Warning = int, Information = int, Hint = int, Unknown = int } } +M.get_diagnostic_counts = function() + local d = vim.diagnostic.get() + local lookup = {} + for _, diag in ipairs(d) do + if diag.source == "Lua Diagnostics." and diag.message == "Undefined global `vim`." then + -- ignore this diagnostic + else + local success, file_name = pcall(vim.api.nvim_buf_get_name, diag.bufnr) + if success then + local sev = diag_severity_to_string(diag.severity) + if sev then + local entry = lookup[file_name] or { severity_number = 4 } + entry[sev] = (entry[sev] or 0) + 1 + entry.severity_number = math.min(entry.severity_number, diag.severity) + entry.severity_string = diag_severity_to_string(entry.severity_number) + lookup[file_name] = entry + end + end + end + end + + for file_name, entry in pairs(lookup) do + -- Now bubble this status up to the parent directories + local parts = M.split(file_name, M.path_separator) + table.remove(parts) -- pop the last part so we don't override the file's status + M.reduce(parts, "", function(acc, part) + local path = (M.is_windows and acc == "") and part or M.path_join(acc, part) + local path_entry = lookup[path] or { severity_number = 4 } + path_entry.severity_number = math.min(path_entry.severity_number, entry.severity_number) + path_entry.severity_string = diag_severity_to_string(path_entry.severity_number) + lookup[path] = path_entry + return path + end) + end + return lookup +end + +--- DEPRECATED: This will be removed in v3. Use `get_opened_buffers` instead. +---Gets a lookup of all open buffers keyed by path with the modifed flag as the value +---@return table opened_buffers { [buffer_name] = bool } +M.get_modified_buffers = function() + local opened_buffers = M.get_opened_buffers() + for bufname, bufinfo in pairs(opened_buffers) do + opened_buffers[bufname] = bufinfo.modified + end + return opened_buffers +end + +---Gets a lookup of all open buffers keyed by path with additional information +---@return table opened_buffers { [buffer_name] = { modified = bool } } +M.get_opened_buffers = function() + local opened_buffers = {} + for _, buffer in ipairs(vim.api.nvim_list_bufs()) do + if vim.fn.buflisted(buffer) ~= 0 then + local buffer_name = vim.api.nvim_buf_get_name(buffer) + if buffer_name == nil or buffer_name == "" then + buffer_name = "[No Name]#" .. buffer + end + opened_buffers[buffer_name] = { + ["modified"] = vim.api.nvim_buf_get_option(buffer, "modified"), + ["loaded"] = vim.api.nvim_buf_is_loaded(buffer), + } + end + end + return opened_buffers +end + +---Resolves some variable to a string. The object can be either a string or a +--function that returns a string. +---@param functionOrString any The object to resolve. +---@param node table The current node, which is passed to the function if it is a function. +---@param state any The current state, which is passed to the function if it is a function. +---@return string string The resolved string. +M.getStringValue = function(functionOrString, node, state) + if type(functionOrString) == "function" then + return functionOrString(node, state) + else + return functionOrString + end +end + +---Return the keys of a given table. +---@param tbl table The table to get the keys of. +---@param sorted boolean Whether to sort the keys. +---@return table table The keys of the table. +M.get_keys = function(tbl, sorted) + local keys = {} + for k, _ in pairs(tbl) do + table.insert(keys, k) + end + if sorted then + table.sort(keys) + end + return keys +end + +---Gets the usable columns in a window, subtracting sign, fold, and line number columns. +---@param winid integer The window id to get the columns of. +---@return number +M.get_inner_win_width = function(winid) + local info = vim.fn.getwininfo(winid) + if info and info[1] then + return info[1].width - info[1].textoff + else + log.error("Could not get window info for window", winid) + end +end + +---Handles null coalescing into a table at any depth. +---@param sourceObject table The table to get a vlue from. +---@param valuePath string The path to the value to get. +---@param defaultValue any The default value to return if the value is nil. +---@param strict_type_check boolean Whether to require the type of the value is +---the same as the default value. +---@return table|nil table The value at the path or the default value. +M.get_value = function(sourceObject, valuePath, defaultValue, strict_type_check) + if sourceObject == nil then + return defaultValue + end + local pathParts = M.split(valuePath, ".") + local currentTable = sourceObject + for _, part in ipairs(pathParts) do + if currentTable[part] == nil then + return defaultValue + else + currentTable = currentTable[part] + end + end + + if currentTable ~= nil then + return currentTable + end + if strict_type_check then + if type(defaultValue) == type(currentTable) then + return currentTable + else + return defaultValue + end + end +end + +---Sets a value at a path in a table, creating any missing tables along the way. +---@param sourceObject table The table to set a value in. +---@param valuePath string The path to the value to set. +---@param value any The value to set. +M.set_value = function(sourceObject, valuePath, value) + local pathParts = M.split(valuePath, ".") + local currentTable = sourceObject + for i, part in ipairs(pathParts) do + if i == #pathParts then + currentTable[part] = value + else + currentTable = currentTable[part] + end + end +end + +---Groups an array of items by a key. +---@param array table The array to group. +---@param key string The key to group by. +---@return table table The grouped array where the keys are the unique values of the specified key. +M.group_by = function(array, key) + local result = {} + for _, item in ipairs(array) do + local keyValue = item[key] + local group = result[keyValue] + if group == nil then + group = {} + result[keyValue] = group + end + table.insert(group, item) + end + return result +end + +---Determines if a file should be filtered by a given list of glob patterns. +---@param pattern_list table The list of glob patterns to filter by. +---@param path string The full path to the file. +---@param name string|nil The name of the file. +---@return boolean +M.is_filtered_by_pattern = function(pattern_list, path, name) + if pattern_list == nil then + return false + end + if name == nil then + _, name = M.split_path(path) + end + for _, p in ipairs(pattern_list) do + local separator_pattern = M.is_windows and "\\" or "/" + local filename = string.find(p, separator_pattern) and path or name + if string.find(filename, p) then + return true + end + end + return false +end + +M.is_floating = function(win_id) + win_id = win_id or vim.api.nvim_get_current_win() + local cfg = vim.api.nvim_win_get_config(win_id) + if cfg.relative > "" or cfg.external then + return true + end + return false +end + +---Evaluates the value of , which comes from an autocmd event, and determines if it +---is a valid file or some sort of utility buffer like quickfix or neo-tree itself. +---@param afile string The path or relative path to the file. +---@param true_for_terminals boolean? Whether to return true for terminals, normally it would be false. +---@return boolean boolean Whether the buffer is a real file. +M.is_real_file = function(afile, true_for_terminals) + if type(afile) ~= "string" or afile == "" or afile == "quickfix" then + return false + end + + local source = afile:match("^neo%-tree ([%l%-]+) %[%d+%]") + if source then + return false + end + + local success, bufnr = pcall(vim.fn.bufnr, afile) + if success and bufnr > 0 then + local buftype = vim.api.nvim_buf_get_option(bufnr, "buftype") + + if true_for_terminals and buftype == "terminal" then + return true + end + -- all other buftypes are not real files + if M.truthy(buftype) then + return false + end + return true + else + return false + end +end + +---Creates a new table from an array with the array items as keys. If a dict like +---table is passed in, those keys will be copied to a new table. +---@param tbl table The table to copy items from. +---@return table table A new dictionary style table. +M.list_to_dict = function(tbl) + local dict = {} + -- leave the existing keys + for key, val in pairs(tbl) do + dict[key] = val + end + -- and convert the number indexed items + for _, item in ipairs(tbl) do + dict[item] = true + end + return dict +end + +M.map = function(tbl, fn) + local t = {} + for k, v in pairs(tbl) do + t[k] = fn(v) + end + return t +end + +M.get_appropriate_window = function(state) + -- Avoid triggering autocommands when switching windows + local eventignore = vim.o.eventignore + vim.o.eventignore = "all" + + local current_window = vim.api.nvim_get_current_win() + + -- use last window if possible + local suitable_window_found = false + local nt = require("neo-tree") + local ignore_ft = nt.config.open_files_do_not_replace_types + local ignore = M.list_to_dict(ignore_ft) + ignore["neo-tree"] = true + if nt.config.open_files_in_last_window then + local prior_window = nt.get_prior_window(ignore) + if prior_window > 0 then + local success = pcall(vim.api.nvim_set_current_win, prior_window) + if success then + suitable_window_found = true + end + end + end + -- find a suitable window to open the file in + if not suitable_window_found then + if state.current_position == "right" then + vim.cmd("wincmd t") + else + vim.cmd("wincmd w") + end + end + local attempts = 0 + while attempts < 5 and not suitable_window_found do + local bt = vim.bo.buftype or "normal" + if ignore[vim.bo.filetype] or ignore[bt] or M.is_floating() then + attempts = attempts + 1 + vim.cmd("wincmd w") + else + suitable_window_found = true + end + end + if not suitable_window_found then + -- go back to the neotree window, this will forve it to open a new split + vim.api.nvim_set_current_win(current_window) + end + + local winid = vim.api.nvim_get_current_win() + local is_neo_tree_window = vim.bo.filetype == "neo-tree" + vim.api.nvim_set_current_win(current_window) + + vim.o.eventignore = eventignore + + return winid, is_neo_tree_window +end + +---Resolves the width to a number +---@param width number|string|function +M.resolve_width = function(width) + local default_width = 40 + local available_width = vim.o.columns + if type(width) == "string" then + if string.sub(width, -1) == "%" then + width = tonumber(string.sub(width, 1, #width - 1)) / 100 + width = width * available_width + else + width = tonumber(width) + end + elseif type(width) == "function" then + width = width() + end + + if type(width) ~= "number" then + width = default_width + end + + return math.floor(width) +end + +---Open file in the appropriate window. +---@param state table The state of the source +---@param path string The file to open +---@param open_cmd string The vimcommand to use to open the file +---@param bufnr number|nil The buffer number to open +M.open_file = function(state, path, open_cmd, bufnr) + open_cmd = open_cmd or "edit" + if open_cmd == "edit" or open_cmd == "e" then + -- If the file is already open, switch to it. + bufnr = bufnr or M.find_buffer_by_name(path) + if bufnr <= 0 then + bufnr = nil + else + open_cmd = "b" + end + end + + if M.truthy(path) then + local escaped_path = vim.fn.fnameescape(path) + local bufnr_or_path = bufnr or escaped_path + local events = require("neo-tree.events") + local result = true + local err = nil + local event_result = events.fire_event(events.FILE_OPEN_REQUESTED, { + state = state, + path = path, + open_cmd = open_cmd, + bufnr = bufnr, + }) or {} + if event_result.handled then + events.fire_event(events.FILE_OPENED, path) + return + end + if state.current_position == "current" then + result, err = pcall(vim.cmd, open_cmd .. " " .. bufnr_or_path) + else + local winid, is_neo_tree_window = M.get_appropriate_window(state) + vim.api.nvim_set_current_win(winid) + -- TODO: make this configurable, see issue #43 + if is_neo_tree_window then + local width = vim.api.nvim_win_get_width(0) + if width == vim.o.columns then + -- Neo-tree must be the only window, restore it's status as a sidebar + width = M.get_value(state, "window.width", 40, false) + width = M.resolve_width(width) + end + + local split_command = "vsplit" + -- respect window position in user config when Neo-tree is the only window + if state.current_position == "left" then + split_command = "rightbelow vs" + elseif state.current_position == "right" then + split_command = "leftabove vs" + end + if path == "[No Name]" then + result, err = pcall(vim.cmd, split_command) + if result then + vim.cmd("b" .. bufnr) + end + else + result, err = pcall(vim.cmd, split_command .. " " .. escaped_path) + end + + vim.api.nvim_win_set_width(winid, width) + else + result, err = pcall(vim.cmd, open_cmd .. " " .. bufnr_or_path) + end + end + if result or err == "Vim(edit):E325: ATTENTION" then + -- fixes #321 + vim.api.nvim_buf_set_option(0, "buflisted", true) + events.fire_event(events.FILE_OPENED, path) + else + log.error("Error opening file:", err) + end + end +end + +M.reduce = function(list, memo, func) + for _, i in ipairs(list) do + memo = func(memo, i) + end + return memo +end + +M.reverse_list = function(list) + local result = {} + for i = #list, 1, -1 do + table.insert(result, list[i]) + end + return result +end + +M.resolve_config_option = function(state, config_option, default_value) + local opt = M.get_value(state, config_option, default_value, false) + if type(opt) == "function" then + local success, val = pcall(opt, state) + if success then + return val + else + log.error("Error resolving config option: " .. config_option .. ": " .. val) + return default_value + end + else + return opt + end +end + +---Normalize a path, to avoid errors when comparing paths. +---@param path string The path to be normalize. +---@return string string The normalized path. +M.normalize_path = function(path) + if M.is_windows then + -- normalize the drive letter to uppercase + path = path:sub(1, 1):upper() .. path:sub(2) + end + return path +end + +---Check if a path is a subpath of another. +--@param base string The base path. +--@param path string The path to check is a subpath. +--@return boolean boolean True if it is a subpath, false otherwise. +M.is_subpath = function(base, path) + if not M.truthy(base) or not M.truthy(path) then + return false + elseif base == path then + return true + end + base = M.normalize_path(base) + path = M.normalize_path(path) + return string.sub(path, 1, string.len(base)) == base +end + +---The file system path separator for the current platform. +M.path_separator = "/" +M.is_windows = vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 +if M.is_windows == true then + M.path_separator = "\\" +end + +---Remove the path separator from the end of a path in a cross-platform way. +---@param path string The path to remove the separator from. +---@return string string The path without any trailing separator. +---@return number count The number of separators removed. +M.remove_trailing_slash = function(path) + if M.is_windows then + return path:gsub("\\$", "") + else + return path:gsub("/$", "") + end +end + +---Sorts a list of paths in the order they would appear in a tree. +---@param paths table The list of paths to sort. +---@return table table The sorted list of paths. +M.sort_by_tree_display = function(paths) + -- first turn the paths into a true tree + local nodes = {} + local index = {} + local function create_nodes(path) + local node = index[path] + if node then + return node + end + local parent, name = M.split_path(path) + node = { + name = name, + path = path, + children = {}, + } + index[path] = node + if parent == nil then + table.insert(nodes, node) + else + local parent_node = index[parent] + if parent_node == nil then + parent_node = create_nodes(parent) + end + table.insert(parent_node.children, node) + end + return node + end + + for _, path in ipairs(paths) do + create_nodes(path) + end + + -- create a lookup of the original paths so that we don't return anything + -- that isn't in the original list + local original_paths = M.list_to_dict(paths) + + -- sort folders before files + local sort_by_name = function(a, b) + local a_isdir = #a.children > 0 + local b_isdir = #b.children > 0 + if a_isdir and not b_isdir then + return true + elseif not a_isdir and b_isdir then + return false + else + return a.name < b.name + end + end + + -- now we can walk the tree in the order that it would be displayed on the screen + local result = {} + local function walk_tree(node) + if original_paths[node.path] then + table.insert(result, node.path) + original_paths[node.path] = nil -- just to be sure we don't return it twice + end + table.sort(node.children, sort_by_name) + for _, child in ipairs(node.children) do + walk_tree(child) + end + end + + walk_tree({ children = nodes }) + return result +end + +---Split string into a table of strings using a separator. +---@param inputString string The string to split. +---@param sep string The separator to use. +---@return table table A table of strings. +M.split = function(inputString, sep) + local fields = {} + + local pattern = string.format("([^%s]+)", sep) + local _ = string.gsub(inputString, pattern, function(c) + fields[#fields + 1] = c + end) + + return fields +end + +---Split a path into a parentPath and a name. +---@param path string The path to split. +---@return string|nil parentPath +---@return string|nil name +M.split_path = function(path) + if not path then + return nil, nil + end + if path == M.path_separator then + return nil, M.path_separator + end + local parts = M.split(path, M.path_separator) + local name = table.remove(parts) + local parentPath = table.concat(parts, M.path_separator) + if M.is_windows then + if #parts == 1 then + parentPath = parentPath .. M.path_separator + elseif parentPath == "" then + return nil, name + end + else + parentPath = M.path_separator .. parentPath + end + return parentPath, name +end + +---Joins arbitrary number of paths together. +---@param ... string The paths to join. +---@return string +M.path_join = function(...) + local args = { ... } + if #args == 0 then + return "" + end + + local all_parts = {} + if type(args[1]) == "string" and args[1]:sub(1, 1) == M.path_separator then + all_parts[1] = "" + end + + for _, arg in ipairs(args) do + if arg == "" and #all_parts == 0 and not M.is_windows then + all_parts = { "" } + else + local arg_parts = M.split(arg, M.path_separator) + vim.list_extend(all_parts, arg_parts) + end + end + return table.concat(all_parts, M.path_separator) +end + +local table_merge_internal +---Merges overrideTable into baseTable. This mutates baseTable. +---@param base_table table The base table that provides default values. +---@param override_table table The table to override the base table with. +---@return table table The merged table. +table_merge_internal = function(base_table, override_table) + for k, v in pairs(override_table) do + if type(v) == "table" then + if type(base_table[k]) == "table" then + table_merge_internal(base_table[k], v) + else + base_table[k] = v + end + else + base_table[k] = v + end + end + return base_table +end + +---DEPRECATED: Use vim.deepcopy(source_table, { noref = 1 }) instead. +M.table_copy = function(source_table) + return vim.deepcopy(source_table, { noref = 1 }) +end + +---DEPRECATED: Use vim.tbl_deep_extend("force", base_table, source_table) instead. +M.table_merge = function(base_table, override_table) + local merged_table = table_merge_internal({}, base_table) + return table_merge_internal(merged_table, override_table) +end + +---Evaluate the truthiness of a value, according to js/python rules. +---@param value any +---@return boolean +M.truthy = function(value) + if value == nil then + return false + end + if type(value) == "boolean" then + return value + end + if type(value) == "string" then + return value > "" + end + if type(value) == "number" then + return value > 0 + end + if type(value) == "table" then + return #vim.tbl_values(value) > 0 + end + return true +end + +M.is_expandable = function(node) + return node.type == "directory" or node:has_children() +end + +M.windowize_path = function(path) + return path:gsub("/", "\\") +end + +M.wrap = function(func, ...) + if type(func) ~= "function" then + error("Expected function, got " .. type(func)) + end + local wrapped_args = { ... } + return function(...) + local all_args = table.pack(table.unpack(wrapped_args), ...) + func(table.unpack(all_args)) + end +end + +---Checks if the given path is hidden using the Windows hidden file/directory logic +---@param path string +---@return boolean +function M.is_hidden(path) + if not M.is_windows then + return false + end + return bit.band(ffi.C.GetFileAttributesA(path), FILE_ATTRIBUTE_HIDDEN) ~= 0 +end + +---Returns a new list that is the result of dedeuplicating a list. +---@param list table The list to deduplicate. +---@return table table The list of unique values. +M.unique = function(list) + local seen = {} + local result = {} + for _, item in ipairs(list) do + if not seen[item] then + table.insert(result, item) + seen[item] = true + end + end + return result +end + +---Splits string by sep on first occurrence. brace_expand_split("a,b,c", ",") -> { "a", "b,c" }. nil if separator not found. +---@param s string: input string +---@param separator string: separator +---@return string, string | nil +local brace_expand_split = function(s, separator) + local pos = 1 + local depth = 0 + while pos <= s:len() do + local c = s:sub(pos, pos) + if c == "\\" then + pos = pos + 1 + elseif c == separator and depth == 0 then + return s:sub(1, pos - 1), s:sub(pos + 1) + elseif c == "{" then + depth = depth + 1 + elseif c == "}" then + if depth > 0 then + depth = depth - 1 + end + end + pos = pos + 1 + end + return s, nil +end + +---Perform brace expansion on a string and return the sequence of the results +---@param s string?: input string which is inside braces, if nil return { "" } +---@return string[] | nil: list of strings each representing the individual expanded strings +local brace_expand_contents = function(s) + if s == nil then -- no closing brace "}" + return { "" } + elseif s == "" then -- brace with no content "{}" + return { "{}" } + end + + ---Generate a sequence from from..to..step and apply `func` + ---@param from string | number: initial value + ---@param to string | number: end value + ---@param step string | number: step value + ---@param func fun(i: number): string | nil function(string | number) -> string | nil: function applied to all values in sequence. if return is nil, the value will be ignored. + ---@return string[]: generated string list + ---@private + local function resolve_sequence(from, to, step, func) + local f, t = tonumber(from), tonumber(to) + local st = (t < f and -1 or 1) * math.abs(tonumber(step) or 1) -- reverse (negative) step if t < f + ---@type string[] + local items = {} + for i = f, t, st do + local r = func(i) + if r ~= nil then + table.insert(items, r) + end + end + return items + end + + ---If pattern matches the input string `s`, apply an expansion by `resolve_func` + ---@param pattern string: regex to match on `s` + ---@param resolve_func fun(from: string, to: string, step: string): string[] + ---@return string[] | nil: expanded sequence or nil if failed + local function try_sequence_on_pattern(pattern, resolve_func) + local from, to, step = string.match(s, pattern) + if from then + return resolve_func(from, to, step) + end + return nil + end + + ---Process numeric sequence expression. e.g. {0..2} -> {0,1,2}, {01..05..2} -> {01,03,05} + local resolve_sequence_num = function(from, to, step) + local format = "%d" + -- Pad strings in the presence of a leading zero + local pattern = "^-?0%d" + if from:match(pattern) or to:match(pattern) then + format = "%0" .. math.max(#from, #to) .. "d" + end + return resolve_sequence(from, to, step, function(i) + return string.format(format, i) + end) + end + + ---Process alphabet sequence expression. e.g. {a..c} -> {a,b,c}, {a..e..2} -> {a,c,e} + local resolve_sequence_char = function(from, to, step) + return resolve_sequence(from:byte(), to:byte(), step, function(i) + return i ~= 92 and string.char(i) or nil -- 92 == '\\' is ignored in bash + end) + end + + local check_list = { + { [=[^(-?%d+)%.%.(-?%d+)%.%.(-?%d+)$]=], resolve_sequence_num }, + { [=[^(-?%d+)%.%.(-?%d+)$]=], resolve_sequence_num }, + { [=[^(%a)%.%.(%a)%.%.(-?%d+)$]=], resolve_sequence_char }, + { [=[^(%a)%.%.(%a)$]=], resolve_sequence_char }, + } + for _, list in ipairs(check_list) do + local regex, func = table.unpack(list) + local sequence = try_sequence_on_pattern(regex, func) + if sequence then + return sequence + end + end + + -- Regular `,` separated expression. x{a,b,c} -> {xa,xb,xc} + local items, tmp_s = {}, nil + tmp_s = s + while tmp_s ~= nil do + items[#items + 1], tmp_s = brace_expand_split(tmp_s, ",") + end + if #items == 1 then -- Only one expansion found. Abort. + return nil + end + return vim.tbl_flatten(items) +end + +---brace_expand: +-- Perform a BASH style brace expansion to generate arbitrary strings. +-- Especially useful for specifying structured file / dir names. +-- USAGE: +-- - `require("neo-tree.utils").brace_expand("x{a..e..2}")` -> `{ "xa", "xc", "xe" }` +-- - `require("neo-tree.utils").brace_expand("file.txt{,.bak}")` -> `{ "file.txt", "file.txt.bak" }` +-- - `require("neo-tree.utils").brace_expand("./{a,b}/{00..02}.lua")` -> `{ "./a/00.lua", "./a/01.lua", "./a/02.lua", "./b/00.lua", "./b/01.lua", "./b/02.lua" }` +-- More examples for BASH style brace expansion can be found here: https://facelessuser.github.io/bracex/ +---@param s string: input string. e.g. {a..e..2} -> {a,c,e}, {00..05..2} -> {00,03,05} +---@return string[]: result of expansion, array with at least one string (one means it failed to expand and the raw string is returned) +M.brace_expand = function(s) + local preamble, postamble = brace_expand_split(s, "{") + if postamble == nil then + return { s } + end + + local expr, postscript, contents = nil, nil, nil + postscript = postamble + while contents == nil do + local old_expr = expr + expr, postscript = brace_expand_split(postscript, "}") + if old_expr then + expr = old_expr .. "}" .. expr + end + if postscript == nil then -- No closing brace found, so we put back the unmatched '{' + preamble = preamble .. "{" + expr, postscript = nil, postamble + end + contents = brace_expand_contents(expr) + end + + -- Concat everything. Pass postscript recursively. + ---@type string[] + local result = {} + for _, item in ipairs(contents) do + for _, suffix in ipairs(M.brace_expand(postscript)) do + result[#result + 1] = table.concat({ preamble, item, suffix }) + end + end + return result +end + +return M diff --git a/bundle/neo-tree.nvim/plugin/neo-tree.vim b/bundle/neo-tree.nvim/plugin/neo-tree.vim new file mode 100644 index 000000000..b7a81c32b --- /dev/null +++ b/bundle/neo-tree.nvim/plugin/neo-tree.vim @@ -0,0 +1,28 @@ +if exists('g:loaded_neo_tree') + finish +endif +let g:loaded_neo_tree = 1 + +if !exists('g:neo_tree_remove_legacy_commands') + command! -nargs=? NeoTreeClose lua require("neo-tree").close_all("") + command! -nargs=? NeoTreeFloat lua require("neo-tree").float("") + command! -nargs=? NeoTreeFocus lua require("neo-tree").focus("") + command! -nargs=? NeoTreeShow lua require("neo-tree").show("", true) + command! -bang NeoTreeReveal lua require("neo-tree").reveal_current_file("filesystem", false, "" == "!") + command! NeoTreeRevealInSplit lua require("neo-tree").reveal_in_split("filesystem", false) + command! NeoTreeShowInSplit lua require("neo-tree").show_in_split("filesystem", false) + + command! -nargs=? NeoTreeFloatToggle lua require("neo-tree").float("", true) + command! -nargs=? NeoTreeFocusToggle lua require("neo-tree").focus("", true, true) + command! -nargs=? NeoTreeShowToggle lua require("neo-tree").show("", true, true, true) + command! -bang NeoTreeRevealToggle lua require("neo-tree").reveal_current_file("filesystem", true, "" == "!") + command! NeoTreeRevealInSplitToggle lua require("neo-tree").reveal_in_split("filesystem", true) + command! NeoTreeShowInSplitToggle lua require("neo-tree").show_in_split("filesystem", true) + + command! NeoTreePasteConfig lua require("neo-tree").paste_default_config() + command! -nargs=? NeoTreeSetLogLevel lua require("neo-tree").set_log_level("") + command! NeoTreeLogs lua require("neo-tree").show_logs() +endif + +command! -nargs=* -complete=custom,v:lua.require'neo-tree.command'.complete_args + \ Neotree lua require("neo-tree.command")._command() diff --git a/bundle/neo-tree.nvim/release.sh b/bundle/neo-tree.nvim/release.sh new file mode 100644 index 000000000..a6fbdff4a --- /dev/null +++ b/bundle/neo-tree.nvim/release.sh @@ -0,0 +1,38 @@ +#/bin/bash +REPO="nvim-neo-tree/neo-tree.nvim" +LAST_VERSION=$(curl --silent "https://api.github.com/repos/$REPO/releases/latest" | jq -r .tag_name) +echo "LAST_VERSION=$LAST_VERSION" +MAJOR=$(cut -d. -f1 <<<"$LAST_VERSION") +MINOR=$(cut -d. -f2 <<<"$LAST_VERSION") +echo + +RELEASE_BRANCH="${1:-v${MAJOR}.x}" +echo "RELEASE_BRANCH=$RELEASE_BRANCH" +NEXT_VERSION=$MAJOR.$((MINOR+1)) +NEW_VERSION="${2:-${NEXT_VERSION}}" +echo "NEW_VERSION=$NEW_VERSION" +echo + +read -p "Are you sure you want to publish this release? " -n 1 -r +echo # (optional) move to a new line +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + [[ "$0" = "$BASH_SOURCE" ]] && exit 1 || return 1 # handle exits from shell or function but don't exit interactive shell +fi + +git fetch +git checkout main +git pull +echo "Merging to ${RELEASE_BRANCH}" +git checkout $RELEASE_BRANCH +git pull +if git merge --ff-only origin/main; then + git push + git tag -a $NEW_VERSION -m "Release ${NEW_VERSION}" + git push origin $NEW_VERSION + echo "Creating Release" + gh release create $NEW_VERSION --generate-notes +else + echo "RELEASE FAILED! Could not fast-forward release to $RELEASE_BRANCH" +fi +git checkout main diff --git a/bundle/neo-tree.nvim/scripts/test.sh b/bundle/neo-tree.nvim/scripts/test.sh new file mode 100644 index 000000000..16f742e63 --- /dev/null +++ b/bundle/neo-tree.nvim/scripts/test.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +set -euo pipefail + +luacov_dir="" + +while [[ $# -gt 0 ]]; do + case "${1}" in + --clean) + shift + echo "[test] cleaning up environment" + rm -rf ./.testcache + echo "[test] envionment cleaned" + ;; + *) + shift + ;; + esac +done + +function setup_environment() { + echo + echo "[test] setting up environment" + echo + + local plugins_dir="./.testcache/site/pack/vendor/start" + if [[ ! -d "${plugins_dir}" ]]; then + mkdir -p "${plugins_dir}" + fi + + if [[ ! -d "${plugins_dir}/nui.nvim" ]]; then + echo "[plugins] nui.nvim: installing..." + git clone https://github.com/MunifTanjim/nui.nvim "${plugins_dir}/nui.nvim" + echo "[plugins] nui.nvim: installed" + echo + fi + + if [[ ! -d "${plugins_dir}/nvim-web-devicons" ]]; then + echo "[plugins] nvim-web-devicons: installing..." + git clone https://github.com/nvim-tree/nvim-web-devicons "${plugins_dir}/nvim-web-devicons" + echo "[plugins] nvim-web-devicons: installed" + echo + fi + + if [[ ! -d "${plugins_dir}/plenary.nvim" ]]; then + echo "[plugins] plenary.nvim: installing..." + git clone https://github.com/nvim-lua/plenary.nvim "${plugins_dir}/plenary.nvim" + # this commit broke luacov + #git -C "${plugins_dir}/plenary.nvim" revert --no-commit 9069d14a120cadb4f6825f76821533f2babcab92 + echo "[plugins] plenary.nvim: installed" + echo + fi + + echo "[test] environment ready" + echo +} + +function luacov_start() { + luacov_dir="$(dirname "$(luarocks which luacov 2>/dev/null | head -1)")" + if [[ "${luacov_dir}" == "." ]]; then + luacov_dir="" + fi + + if test -n "${luacov_dir}"; then + rm -f luacov.*.out + export LUA_PATH=";;${luacov_dir}/?.lua" + fi +} + +function luacov_end() { + if test -n "${luacov_dir}"; then + luacov + + echo + tail -n +$(($(grep -n "^Summary$" luacov.report.out | cut -d":" -f1) - 1)) luacov.report.out + fi +} + +setup_environment + +#luacov_start + +make test + +#luacov_end diff --git a/bundle/neo-tree.nvim/tests/mininit.lua b/bundle/neo-tree.nvim/tests/mininit.lua new file mode 100644 index 000000000..f5239da4a --- /dev/null +++ b/bundle/neo-tree.nvim/tests/mininit.lua @@ -0,0 +1,30 @@ +-- Need the absolute path as when doing the testing we will issue things like `tcd` to change directory +-- to where our temporary filesystem lives +local root_dir = vim.fn.fnamemodify(vim.trim(vim.fn.system("git rev-parse --show-toplevel")), ":p") + +package.path = string.format("%s;%s?.lua;%s?/init.lua", package.path, root_dir, root_dir) + +vim.opt.packpath:prepend(string.format("%s", root_dir .. ".testcache/site")) + +vim.opt.rtp = { + root_dir, + vim.env.VIMRUNTIME, +} + +vim.cmd([[ + filetype on + packadd plenary.nvim + packadd nui.nvim + packadd nvim-web-devicons +]]) + +vim.opt.swapfile = false + +vim.cmd([[ + runtime plugin/neo-tree.vim +]]) + +-- For debugging +P = function(...) + print(unpack(vim.tbl_map(vim.inspect, { ... }))) +end diff --git a/bundle/neo-tree.nvim/tests/neo-tree/command/command_current_spec.lua b/bundle/neo-tree.nvim/tests/neo-tree/command/command_current_spec.lua new file mode 100644 index 000000000..324097e79 --- /dev/null +++ b/bundle/neo-tree.nvim/tests/neo-tree/command/command_current_spec.lua @@ -0,0 +1,102 @@ +pcall(require, "luacov") + +local Path = require("plenary.path") +local u = require("tests.utils") +local verify = require("tests.utils.verify") + +local run_in_current_command = function(command, expected_tree_node) + local winid = vim.api.nvim_get_current_win() + + vim.cmd(command) + verify.window_handle_is(winid) + verify.buf_name_endswith(string.format("neo-tree filesystem [%s]", winid), 1000) + if expected_tree_node then + verify.filesystem_tree_node_is(expected_tree_node, winid) + end +end + +describe("Command", function() + local test = u.fs.init_test({ + items = { + { + name = "foo", + type = "dir", + items = { + { + name = "bar", + type = "dir", + items = { + { name = "baz1.txt", type = "file" }, + { name = "baz2.txt", type = "file", id = "deepfile2" }, + }, + }, + }, + }, + { name = "topfile1.txt", type = "file", id = "topfile1" }, + { name = "topfile2.txt", type = "file", id = "topfile2" }, + }, + }) + + test.setup() + + local fs_tree = test.fs_tree + + after_each(function() + u.clear_environment() + end) + + describe("netrw style:", function() + it("`:Neotree current` should show neo-tree in current window", function() + local cmd = "Neotree current" + run_in_current_command(cmd) + end) + + it( + "`:Neotree current reveal` should show neo-tree and reveal file in current window", + function() + local cmd = "Neotree current reveal" + local testfile = fs_tree.lookup["topfile1"].abspath + u.editfile(testfile) + run_in_current_command(cmd, testfile) + end + ) + + it("`:Neotree current reveal toggle` should toggle neo-tree in current window", function() + local cmd = "Neotree current reveal toggle" + local testfile = fs_tree.lookup["topfile1"].abspath + u.editfile(testfile) + local tree_winid = vim.api.nvim_get_current_win() + + -- toggle OPEN + run_in_current_command(cmd, testfile) + + -- toggle CLOSE + vim.cmd(cmd) + verify.window_handle_is(tree_winid) + verify.buf_name_is(testfile) + end) + + it( + "`:Neotree current reveal_force_cwd reveal_file=xyz` should reveal file current window if cwd is not a parent of file", + function() + vim.cmd("cd ~") + local testfile = fs_tree.lookup["deepfile2"].abspath + local cmd = "Neotree current reveal_force_cwd reveal_file=" .. testfile + run_in_current_command(cmd, testfile) + end + ) + + it( + "`:Neotree current reveal_force_cwd reveal_file=xyz` should reveal file current window if cwd is a parent of file", + function() + local testfile = fs_tree.lookup["deepfile2"].abspath + local testfile_dir = Path:new(testfile):parent().filename + vim.cmd(string.format("cd %s", testfile_dir)) + local cmd = "Neotree current reveal_force_cwd reveal_file=" .. testfile + run_in_current_command(cmd, testfile) + end + ) + end) + + test.teardown() +end) diff --git a/bundle/neo-tree.nvim/tests/neo-tree/command/command_spec.lua b/bundle/neo-tree.nvim/tests/neo-tree/command/command_spec.lua new file mode 100644 index 000000000..35896d051 --- /dev/null +++ b/bundle/neo-tree.nvim/tests/neo-tree/command/command_spec.lua @@ -0,0 +1,206 @@ +pcall(require, "luacov") + +local u = require("tests.utils") +local verify = require("tests.utils.verify") + +local run_focus_command = function(command, expected_tree_node) + local winid = vim.api.nvim_get_current_win() + + vim.cmd(command) + verify.window_handle_is_not(winid) + verify.buf_name_endswith("neo-tree filesystem [1]") + if expected_tree_node then + verify.filesystem_tree_node_is(expected_tree_node) + end +end + +local run_show_command = function(command, expected_tree_node) + local starting_winid = vim.api.nvim_get_current_win() + local starting_bufname = vim.api.nvim_buf_get_name(0) + local expected_num_windows = #vim.api.nvim_list_wins() + 1 + + vim.cmd(command) + verify.eventually(500, function() + if #vim.api.nvim_list_wins() ~= expected_num_windows then + return false + end + if vim.api.nvim_get_current_win() ~= starting_winid then + return false + end + if vim.api.nvim_buf_get_name(0) ~= starting_bufname then + return false + end + if expected_tree_node then + verify.filesystem_tree_node_is(expected_tree_node) + end + return true + end, "Expected to see a new window without focusing it.") +end + +describe("Command", function() + local test = u.fs.init_test({ + items = { + { + name = "foo", + type = "dir", + items = { + { + name = "bar", + type = "dir", + items = { + { name = "baz1.txt", type = "file" }, + { name = "baz2.txt", type = "file", id = "deepfile2" }, + }, + }, + { name = "foofile1.txt", type = "file" }, + }, + }, + { name = "topfile1.txt", type = "file", id = "topfile1" }, + }, + }) + + test.setup() + + local fs_tree = test.fs_tree + + after_each(function() + u.clear_environment() + end) + + describe("with reveal:", function() + it("`:Neotree float reveal` should reveal the current file in the floating window", function() + local cmd = "Neotree float reveal" + local testfile = fs_tree.lookup["./foo/bar/baz1.txt"].abspath + u.editfile(testfile) + run_focus_command(cmd, testfile) + end) + + it("`:Neotree reveal toggle` should toggle the reveal-state of the tree", function() + local cmd = "Neotree reveal toggle" + local testfile = fs_tree.lookup["./foo/foofile1.txt"].abspath + u.editfile(testfile) + + -- toggle OPEN + run_focus_command(cmd, testfile) + local tree_winid = vim.api.nvim_get_current_win() + + -- toggle CLOSE + vim.cmd(cmd) + verify.window_handle_is_not(tree_winid) + verify.buf_name_is(testfile) + + -- toggle OPEN with a different file + testfile = fs_tree.lookup["./foo/bar/baz1.txt"].abspath + u.editfile(testfile) + run_focus_command(cmd, testfile) + end) + + it( + "`:Neotree float reveal toggle` should toggle the reveal-state of the floating window", + function() + local cmd = "Neotree float reveal toggle" + local testfile = fs_tree.lookup["./foo/foofile1.txt"].abspath + u.editfile(testfile) + + -- toggle OPEN + run_focus_command(cmd, testfile) + local tree_winid = vim.api.nvim_get_current_win() + + -- toggle CLOSE + vim.cmd("Neotree float reveal toggle") + verify.window_handle_is_not(tree_winid) + verify.buf_name_is(testfile) + + -- toggle OPEN + testfile = fs_tree.lookup["./foo/bar/baz2.txt"].abspath + u.editfile(testfile) + run_focus_command(cmd, testfile) + end + ) + + it("`:Neotree reveal` should reveal the current file in the sidebar", function() + local cmd = "Neotree reveal" + local testfile = fs_tree.lookup["topfile1"].abspath + u.editfile(testfile) + run_focus_command(cmd, testfile) + end) + end) + + for _, follow_current_file in ipairs({ true, false }) do + require("neo-tree").setup({ + filesystem = { + follow_current_file = follow_current_file, + }, + }) + + describe(string.format("w/ follow_current_file=%s", follow_current_file), function() + describe("with show :", function() + it("`:Neotree show` should show the window without focusing", function() + local cmd = "Neotree show" + local testfile = fs_tree.lookup["topfile1"].abspath + u.editfile(testfile) + run_show_command(cmd) + end) + + it("`:Neotree show toggle` should retain the focused node on next show", function() + local cmd = "Neotree show toggle" + local topfile = fs_tree.lookup["topfile1"].abspath + local baz = fs_tree.lookup["./foo/bar/baz1.txt"].abspath + + -- focus a sub node to see if state is retained + u.editfile(baz) + run_focus_command(":Neotree reveal", baz) + local expected_tree_node = baz + + verify.after(500, function() + -- toggle CLOSE + vim.cmd(cmd) + + -- toggle OPEN + u.editfile(topfile) + if follow_current_file then + expected_tree_node = topfile + end + run_show_command(cmd, expected_tree_node) + return true + end) + end) + end) + + describe("with focus :", function() + it("`:Neotree focus` should show the window and focus it", function() + local cmd = "Neotree focus" + local testfile = fs_tree.lookup["topfile1"].abspath + u.editfile(testfile) + run_focus_command(cmd) + end) + + it("`:Neotree focus toggle` should retain the focused node on next focus", function() + local cmd = "Neotree focus toggle" + local topfile = fs_tree.lookup["topfile1"].abspath + local baz = fs_tree.lookup["./foo/bar/baz1.txt"].abspath + + -- focus a sub node to see if state is retained + u.editfile(baz) + run_focus_command("Neotree reveal", baz) + local expected_tree_node = baz + + verify.after(500, function() + -- toggle CLOSE + vim.cmd(cmd) + + -- toggle OPEN + u.editfile(topfile) + if follow_current_file then + expected_tree_node = topfile + end + run_focus_command(cmd, expected_tree_node) + return true + end) + end) + end) + end) + end + + test.teardown() +end) diff --git a/bundle/neo-tree.nvim/tests/neo-tree/events/queue_spec.lua b/bundle/neo-tree.nvim/tests/neo-tree/events/queue_spec.lua new file mode 100644 index 000000000..a3a6c642d --- /dev/null +++ b/bundle/neo-tree.nvim/tests/neo-tree/events/queue_spec.lua @@ -0,0 +1,28 @@ +pcall(require, "luacov") + +describe("Event queue", function() + it("should return data when handled = true", function() + local events = require("neo-tree.events") + events.subscribe({ + event = "test", + handler = function() + return { data = "first" } + end, + }) + events.subscribe({ + event = "test", + handler = function() + return { handled = true, data = "second" } + end, + }) + events.subscribe({ + event = "test", + handler = function() + return { data = "third" } + end, + }) + local result = events.fire_event("test") or {} + local data = result.data + assert.are.same("second", data) + end) +end) diff --git a/bundle/neo-tree.nvim/tests/neo-tree/sources/container_spec.lua b/bundle/neo-tree.nvim/tests/neo-tree/sources/container_spec.lua new file mode 100644 index 000000000..9fc735bdd --- /dev/null +++ b/bundle/neo-tree.nvim/tests/neo-tree/sources/container_spec.lua @@ -0,0 +1,146 @@ +pcall(require, "luacov") + +local ns_id = require("neo-tree.ui.highlights").ns_id +local u = require("tests.utils") + +local config = { + renderers = { + directory = { + { + "container", + content = { + { "indent", zindex = 10 }, + { "icon", zindex = 10 }, + { "name", zindex = 10 }, + { "name", zindex = 5, align = "right" }, + }, + }, + }, + file = { + { + "container", + content = { + { "indent", zindex = 10 }, + { "icon", zindex = 10 }, + { "name", zindex = 10 }, + { "name", zindex = 20, align = "right" }, + }, + }, + }, + }, + window = { + width = 40, + }, +} + +local config_right = { + renderers = { + directory = { + { + "container", + enable_character_fade = false, + content = { + { "indent", zindex = 10, align = "right" }, + { "icon", zindex = 10, align = "right" }, + { "name", zindex = 10, align = "right" }, + }, + }, + }, + file = { + { + "container", + enable_character_fade = false, + content = { + { "indent", zindex = 10, align = "right" }, + { "icon", zindex = 10, align = "right" }, + { "name", zindex = 10, align = "right" }, + }, + }, + }, + }, + window = { + width = 40, + }, +} + +local test_dir = { + items = { + { + name = "foo", + type = "dir", + items = { + { + name = "bar", + type = "dir", + items = { + { name = "bar1.txt", type = "file" }, + { name = "bar2.txt", type = "file" }, + }, + }, + { name = "foo1.lua", type = "file" }, + }, + }, + { name = "bazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbaz", type = "dir" }, + { name = "1.md", type = "file" }, + }, +} + +describe("sources/components/container", function() + local req_switch = u.get_require_switch() + + local test = u.fs.init_test(test_dir) + test.setup() + + after_each(function() + if req_switch then + req_switch.restore() + end + + u.clear_environment() + end) + + describe("should expand to width", function() + for pow = 4, 8 do + it(2 ^ pow, function() + config.window.width = 2 ^ pow + require("neo-tree").setup(config) + vim.cmd([[Neotree focus]]) + u.wait_for(function() + return vim.bo.filetype == "neo-tree" + end) + + assert.equals(vim.bo.filetype, "neo-tree") + + local width = vim.api.nvim_win_get_width(0) + local lines = vim.api.nvim_buf_get_lines(0, 2, -1, false) + for _, line in ipairs(lines) do + assert.is_true(#line >= width) + end + end) + end + end) + + describe("right-align should matches width", function() + for pow = 4, 8 do + it(2 ^ pow, function() + config_right.window.width = 2 ^ pow + require("neo-tree").setup(config_right) + vim.cmd([[Neotree focus]]) + u.wait_for(function() + return vim.bo.filetype == "neo-tree" + end) + + assert.equals(vim.bo.filetype, "neo-tree") + + local width = vim.api.nvim_win_get_width(0) + local lines = vim.api.nvim_buf_get_lines(0, 1, -1, false) + for _, line in ipairs(lines) do + line = vim.fn.trim(line, " ", 2) + assert.equals(width, vim.fn.strchars(line)) + end + end) + end + end) + + test.teardown() +end) diff --git a/bundle/neo-tree.nvim/tests/neo-tree/sources/filesystem/filesystem_command_spec.lua b/bundle/neo-tree.nvim/tests/neo-tree/sources/filesystem/filesystem_command_spec.lua new file mode 100644 index 000000000..ab17dfa78 --- /dev/null +++ b/bundle/neo-tree.nvim/tests/neo-tree/sources/filesystem/filesystem_command_spec.lua @@ -0,0 +1,204 @@ +pcall(require, "luacov") + +local u = require("tests.utils") +local verify = require("tests.utils.verify") + +local run_focus_command = function(command, expected_tree_node) + local winid = vim.api.nvim_get_current_win() + + vim.cmd(command) + verify.window_handle_is_not(winid) + verify.buf_name_endswith("neo-tree filesystem [1]") + if expected_tree_node then + verify.filesystem_tree_node_is(expected_tree_node) + end +end + +local run_show_command = function(command, expected_tree_node) + local starting_winid = vim.api.nvim_get_current_win() + local starting_bufname = vim.api.nvim_buf_get_name(0) + local expected_num_windows = #vim.api.nvim_list_wins() + 1 + + vim.cmd(command) + verify.eventually(500, function() + if #vim.api.nvim_list_wins() ~= expected_num_windows then + return false + end + if vim.api.nvim_get_current_win() ~= starting_winid then + return false + end + if vim.api.nvim_buf_get_name(0) ~= starting_bufname then + return false + end + if expected_tree_node then + verify.filesystem_tree_node_is(expected_tree_node) + end + return true + end, "Expected to see a new window without focusing it.") +end + +describe("Filesystem", function() + local test = u.fs.init_test({ + items = { + { + name = "foo", + type = "dir", + items = { + { + name = "bar", + type = "dir", + items = { + { name = "baz1.txt", type = "file" }, + { name = "baz2.txt", type = "file", id = "deepfile2" }, + }, + }, + { name = "foofile1.txt", type = "file" }, + }, + }, + { name = "topfile1.txt", type = "file", id = "topfile1" }, + }, + }) + + test.setup() + + local fs_tree = test.fs_tree + + after_each(function() + u.clear_environment() + end) + + describe("reveal command", function() + it("should reveal the current file in the sidebar", function() + local cmd = "NeoTreeReveal" + local testfile = fs_tree.lookup["topfile1"].abspath + u.editfile(testfile) + run_focus_command(cmd, testfile) + end) + + it("should reveal the current file in the floating window", function() + local cmd = "NeoTreeFloat" + local testfile = fs_tree.lookup["./foo/bar/baz1.txt"].abspath + u.editfile(testfile) + run_focus_command(cmd, testfile) + end) + + it("should toggle the reveal-state of the tree", function() + local cmd = "NeoTreeRevealToggle" + local testfile = fs_tree.lookup["./foo/foofile1.txt"].abspath + u.editfile(testfile) + + -- toggle OPEN + run_focus_command(cmd, testfile) + local tree_winid = vim.api.nvim_get_current_win() + + -- toggle CLOSE + vim.cmd(cmd) + verify.window_handle_is_not(tree_winid) + verify.buf_name_is(testfile) + + -- toggle OPEN with a different file + testfile = fs_tree.lookup["./foo/bar/baz1.txt"].abspath + u.editfile(testfile) + run_focus_command(cmd, testfile) + end) + + it("should toggle the reveal-state of the floating window", function() + local cmd = "NeoTreeFloatToggle" + local testfile = fs_tree.lookup["./foo/foofile1.txt"].abspath + u.editfile(testfile) + + -- toggle OPEN + run_focus_command(cmd, testfile) + local tree_winid = vim.api.nvim_get_current_win() + + -- toggle CLOSE + vim.cmd("NeoTreeRevealToggle") + verify.window_handle_is_not(tree_winid) + verify.buf_name_is(testfile) + vim.wait(1000) + + -- toggle OPEN + testfile = fs_tree.lookup["./foo/bar/baz2.txt"].abspath + u.editfile(testfile) + run_focus_command(cmd, testfile) + end) + end) + + for _, follow_current_file in ipairs({ true, false }) do + require("neo-tree").setup({ + filesystem = { + follow_current_file = follow_current_file, + }, + }) + + describe(string.format("w/ follow_current_file=%s", follow_current_file), function() + describe("show command", function() + it("should show the window without focusing", function() + local cmd = "NeoTreeShow" + local testfile = fs_tree.lookup["topfile1"].abspath + u.editfile(testfile) + run_show_command(cmd) + end) + + it("should retain the focused node on next show", function() + local cmd = "NeoTreeShowToggle" + local topfile = fs_tree.lookup["topfile1"].abspath + local baz = fs_tree.lookup["./foo/bar/baz1.txt"].abspath + + -- focus a sub node to see if state is retained + u.editfile(baz) + run_focus_command("NeoTreeReveal", baz) + local expected_tree_node = baz + + verify.after(500, function() + -- toggle CLOSE + vim.cmd(cmd) + + -- toggle OPEN + u.editfile(topfile) + if follow_current_file then + expected_tree_node = topfile + end + run_show_command(cmd, expected_tree_node) + return true + end) + end) + end) + + describe("focus command", function() + it("should show the window and focus it", function() + local cmd = "NeoTreeFocus" + local testfile = fs_tree.lookup["topfile1"].abspath + u.editfile(testfile) + run_focus_command(cmd) + end) + + it("should retain the focused node on next focus", function() + local cmd = "NeoTreeFocusToggle" + local topfile = fs_tree.lookup["topfile1"].abspath + local baz = fs_tree.lookup["./foo/bar/baz1.txt"].abspath + + -- focus a sub node to see if state is retained + u.editfile(baz) + run_focus_command("NeoTreeReveal", baz) + local expected_tree_node = baz + + verify.after(500, function() + -- toggle CLOSE + vim.cmd(cmd) + + -- toggle OPEN + u.editfile(topfile) + if follow_current_file then + expected_tree_node = topfile + end + run_focus_command(cmd, expected_tree_node) + return true + end) + end) + end) + end) + end + + test.teardown() +end) diff --git a/bundle/neo-tree.nvim/tests/neo-tree/sources/filesystem/filesystem_netrw_hijack_spec.lua b/bundle/neo-tree.nvim/tests/neo-tree/sources/filesystem/filesystem_netrw_hijack_spec.lua new file mode 100644 index 000000000..7d02daaa4 --- /dev/null +++ b/bundle/neo-tree.nvim/tests/neo-tree/sources/filesystem/filesystem_netrw_hijack_spec.lua @@ -0,0 +1,92 @@ +pcall(require, "luacov") + +local u = require("tests.utils") +local verify = require("tests.utils.verify") + +describe("Filesystem netrw hijack", function() + after_each(function() + u.clear_environment() + end) + + it("does not interfere with netrw when disabled", function() + require("neo-tree").setup({ + filesystem = { + hijack_netrw_behavior = "disabled", + window = { + position = "left", + }, + }, + }) + + vim.cmd("edit .") + + assert(#vim.api.nvim_list_wins() == 1, "there should only be one window") + + verify.after(100, function() + local name = vim.api.nvim_buf_get_name(0) + return name ~= "neo-tree filesystem [1]" + end, "the buffer should not be neo-tree") + end) + + it("opens in sidebar when behavior is open_default", function() + local file = "Makefile" + vim.cmd("edit " .. file) + + require("neo-tree").setup({ + filesystem = { + hijack_netrw_behavior = "open_default", + window = { + position = "left", + }, + }, + }) + + vim.cmd("edit .") + + verify.eventually(200, function() + return #vim.api.nvim_list_wins() == 2 + end, "there should be two windows") + + verify.buf_name_endswith("neo-tree filesystem [1]") + + verify.eventually(100, function() + local expected_buf_name = "Makefile" + local buf_at_2 = vim.api.nvim_win_get_buf(vim.fn.win_getid(2)) + local name_at_2 = vim.api.nvim_buf_get_name(buf_at_2) + if name_at_2:sub(-#expected_buf_name) == expected_buf_name then + return true + else + return false + end + end, file .. " is not at window 2") + end) + + it("opens in in splits when behavior is open_current", function() + local file = "Makefile" + vim.cmd("edit " .. file) + + require("neo-tree").setup({ + filesystem = { + hijack_netrw_behavior = "open_current", + }, + }) + + assert(#vim.api.nvim_list_wins() == 1, "Test should start with one window") + + vim.cmd("edit .") + + verify.eventually(200, function() + assert(#vim.api.nvim_list_wins() == 1, "`edit .` should not open a new window") + return vim.api.nvim_buf_get_option(0, "filetype") == "neo-tree" + end, "neotree is not the only window") + + vim.cmd("split .") + + verify.eventually(200, function() + if #vim.api.nvim_list_wins() ~= 2 then + return false + end + return vim.api.nvim_buf_get_option(0, "filetype") == "neo-tree" + end, "neotree is not in the second window") + end) +end) diff --git a/bundle/neo-tree.nvim/tests/neo-tree/sources/manager_spec.lua b/bundle/neo-tree.nvim/tests/neo-tree/sources/manager_spec.lua new file mode 100644 index 000000000..b264ef5ee --- /dev/null +++ b/bundle/neo-tree.nvim/tests/neo-tree/sources/manager_spec.lua @@ -0,0 +1,125 @@ +pcall(require, "luacov") + +local u = require("tests.utils") +local verify = require("tests.utils.verify") + +local manager = require('neo-tree.sources.manager') + +local get_dirs = function(winid) + winid = winid or vim.api.nvim_get_current_win() + local tabnr = vim.api.nvim_tabpage_get_number(vim.api.nvim_win_get_tabpage(winid)) + local winnr = vim.api.nvim_win_get_number(winid) + return { + win = vim.fn.getcwd(winnr), + tab = vim.fn.getcwd(-1, tabnr), + global = vim.fn.getcwd(-1, -1), + } +end + +local get_state_for_tab = function(tabid) + for _, state in ipairs(manager._get_all_states()) do + if state.tabid == tabid then + return state + end + end +end + +local get_tabnr = function(tabid) + return vim.api.nvim_tabpage_get_number(tabid or vim.api.nvim_get_current_tabpage()) +end + +describe("Manager", function() + local test = u.fs.init_test({ + items = { + { + name = "foo", + type = "dir", + items = { + { name = "foofile1.txt", type = "file" }, + }, + }, + { name = "topfile1.txt", type = "file", id = "topfile1" }, + }, + }) + + test.setup() + + local fs_tree = test.fs_tree + + -- Just make sure we start all tests in the expected state + before_each(function() + u.eq(1, #vim.api.nvim_list_wins()) + u.eq(1, #vim.api.nvim_list_tabpages()) + vim.cmd.lcd(fs_tree.abspath) + vim.cmd.tcd(fs_tree.abspath) + vim.cmd.cd(fs_tree.abspath) + end) + + after_each(function() + u.clear_environment() + end) + + local setup_2_tabs = function() + -- create 2 tabs + local tab1 = vim.api.nvim_get_current_tabpage() + local win1 = vim.api.nvim_get_current_win() + vim.cmd.tabnew() + local tab2 = vim.api.nvim_get_current_tabpage() + local win2 = vim.api.nvim_get_current_win() + u.neq(tab2, tab1) + u.neq(win2, win1) + + -- set different directories + vim.api.nvim_set_current_tabpage(tab2) + local base_dir = vim.fn.getcwd() + vim.cmd.tcd('foo') + local new_dir = vim.fn.getcwd() + + -- open neo-tree + vim.api.nvim_set_current_tabpage(tab1) + vim.cmd.Neotree('show') + vim.api.nvim_set_current_tabpage(tab2) + vim.cmd.Neotree('show') + + return { + tab1 = tab1, + tab2 = tab2, + win1 = win1, + win2 = win2, + tab1_dir = base_dir, + tab2_dir = new_dir, + } + end + + it("should respect changed tab cwd", function() + local ctx = setup_2_tabs() + + local state1 = get_state_for_tab(ctx.tab1) + local state2 = get_state_for_tab(ctx.tab2) + u.eq(ctx.tab1_dir, manager.get_cwd(state1)) + u.eq(ctx.tab2_dir, manager.get_cwd(state2)) + end) + + it("should have correct tab cwd after tabs order is changed", function() + local ctx = setup_2_tabs() + + -- tab numbers should be the same as ids + u.eq(1, get_tabnr(ctx.tab1)) + u.eq(2, get_tabnr(ctx.tab2)) + + -- swap tabs + vim.cmd.tabfirst() + vim.cmd.tabmove('+1') + + -- make sure tabs have been swapped + u.eq(2, get_tabnr(ctx.tab1)) + u.eq(1, get_tabnr(ctx.tab2)) + + -- verify that tab dirs are the same as nvim tab cwd + local state1 = get_state_for_tab(ctx.tab1) + local state2 = get_state_for_tab(ctx.tab2) + u.eq(get_dirs(ctx.win1).tab, manager.get_cwd(state1)) + u.eq(get_dirs(ctx.win2).tab, manager.get_cwd(state2)) + end) +end) + diff --git a/bundle/neo-tree.nvim/tests/neo-tree/ui/icons_spec.lua b/bundle/neo-tree.nvim/tests/neo-tree/ui/icons_spec.lua new file mode 100644 index 000000000..d1ded8a2e --- /dev/null +++ b/bundle/neo-tree.nvim/tests/neo-tree/ui/icons_spec.lua @@ -0,0 +1,235 @@ +pcall(require, "luacov") + +local ns_id = require("neo-tree.ui.highlights").ns_id +local u = require("tests.utils") + +describe("ui/icons", function() + local req_switch = u.get_require_switch() + + local test = u.fs.init_test({ + items = { + { + name = "foo", + type = "dir", + items = { + { + name = "bar", + type = "dir", + items = { + { name = "bar1.txt", type = "file" }, + { name = "bar2.txt", type = "file" }, + }, + }, + { name = "foo1.lua", type = "file" }, + }, + }, + { name = "baz", type = "dir" }, + { name = "1.md", type = "file" }, + }, + }) + + test.setup() + + local fs_tree = test.fs_tree + + after_each(function() + if req_switch then + req_switch.restore() + end + + u.clear_environment() + end) + + describe("w/ default_config", function() + before_each(function() + require("neo-tree").setup({}) + end) + + it("works w/o nvim-web-devicons", function() + req_switch.disable_package("nvim-web-devicons") + + vim.cmd([[:Neotree focus]]) + u.wait_for(function() + return vim.bo.filetype == "neo-tree" + end) + + local winid = vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(winid) + + u.assert_buf_lines(bufnr, { + string.format("  %s", fs_tree.abspath):sub(1, 42), + "  baz", + "  foo", + " * 1.md", + }) + + vim.api.nvim_win_set_cursor(winid, { 2, 0 }) + u.feedkeys("") + + vim.api.nvim_win_set_cursor(winid, { 3, 0 }) + u.feedkeys("") + + vim.wait(100) + + u.assert_buf_lines(bufnr, { + string.format("  %s", fs_tree.abspath):sub(1, 42), + " ﰊ baz", + "  foo", + " │  bar", + " └ * foo1.lua", + " * 1.md", + }) + + u.assert_highlight(bufnr, ns_id, 1, " ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 2, "ﰊ ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 4, " ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 5, "* ", "NeoTreeFileIcon") + end) + + it("works w/ nvim-web-devicons", function() + vim.cmd([[:Neotree focus]]) + u.wait_for(function() + return vim.bo.filetype == "neo-tree" + end) + + local winid = vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(winid) + + u.assert_buf_lines(bufnr, { + vim.fn.strcharpart(string.format("  %s", fs_tree.abspath), 0, 40), + "  baz", + "  foo", + "  1.md", + }) + + vim.api.nvim_win_set_cursor(winid, { 2, 0 }) + u.feedkeys("") + + vim.api.nvim_win_set_cursor(winid, { 3, 0 }) + u.feedkeys("") + + vim.wait(100) + + u.assert_buf_lines(bufnr, { + vim.fn.strcharpart(string.format("  %s", fs_tree.abspath), 0, 40), + " ﰊ baz", + "  foo", + " │  bar", + " └  foo1.lua", + "  1.md", + }) + + u.assert_highlight(bufnr, ns_id, 1, " ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 2, "ﰊ ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 4, " ", "NeoTreeDirectoryIcon") + + local extmarks = u.get_text_extmarks(bufnr, ns_id, 5, " ") + u.eq(#extmarks, 1) + u.neq(extmarks[1][4].hl_group, "NeoTreeFileIcon") + end) + end) + + describe("custom config", function() + local config + before_each(function() + config = { + default_component_configs = { + icon = { + folder_closed = "c", + folder_open = "o", + folder_empty = "e", + default = "f", + highlight = "TestNeoTreeFileIcon", + }, + }, + } + + require("neo-tree").setup(config) + end) + + it("works w/o nvim-web-devicons", function() + req_switch.disable_package("nvim-web-devicons") + + vim.cmd([[:Neotree focus]]) + u.wait_for(function() + return vim.bo.filetype == "neo-tree" + end) + + local winid = vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(winid) + + u.assert_buf_lines(bufnr, { + string.format(" o %s", fs_tree.abspath):sub(1, 40), + " c baz", + " c foo", + " f 1.md", + }) + + vim.api.nvim_win_set_cursor(winid, { 2, 0 }) + u.feedkeys("") + + vim.api.nvim_win_set_cursor(winid, { 3, 0 }) + u.feedkeys("") + + vim.wait(100) + + u.assert_buf_lines(bufnr, { + string.format(" o %s", fs_tree.abspath):sub(1, 40), + " e baz", + " o foo", + " │ c bar", + " └ f foo1.lua", + " f 1.md", + }) + + u.assert_highlight(bufnr, ns_id, 1, "o ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 2, "e ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 4, "c ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 5, "f ", config.default_component_configs.icon.highlight) + end) + + it("works w/ nvim-web-devicons", function() + vim.cmd([[:Neotree focus]]) + u.wait_for(function() + return vim.bo.filetype == "neo-tree" + end) + + local winid = vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_win_get_buf(winid) + + u.assert_buf_lines(bufnr, { + vim.fn.strcharpart(string.format(" o %s", fs_tree.abspath), 0, 40), + " c baz", + " c foo", + "  1.md", + }) + + vim.api.nvim_win_set_cursor(winid, { 2, 0 }) + u.feedkeys("") + + vim.api.nvim_win_set_cursor(winid, { 3, 0 }) + u.feedkeys("") + + vim.wait(100) + + u.assert_buf_lines(bufnr, { + vim.fn.strcharpart(string.format(" o %s", fs_tree.abspath), 0, 40), + " e baz", + " o foo", + " │ c bar", + " └  foo1.lua", + "  1.md", + }) + + u.assert_highlight(bufnr, ns_id, 1, "o ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 2, "e ", "NeoTreeDirectoryIcon") + u.assert_highlight(bufnr, ns_id, 4, "c ", "NeoTreeDirectoryIcon") + + local extmarks = u.get_text_extmarks(bufnr, ns_id, 5, " ") + u.eq(#extmarks, 1) + u.neq(extmarks[1][4].hl_group, config.default_component_configs.icon.highlight) + end) + end) + + test.teardown() +end) diff --git a/bundle/neo-tree.nvim/tests/utils/fs.lua b/bundle/neo-tree.nvim/tests/utils/fs.lua new file mode 100644 index 000000000..66e35a8cb --- /dev/null +++ b/bundle/neo-tree.nvim/tests/utils/fs.lua @@ -0,0 +1,94 @@ +local Path = require("plenary.path") + +local fs = {} + +function fs.create_temp_dir() + -- Resolve for two reasons. + -- 1. Follow any symlinks which make comparing paths fail. (on macOS, TMPDIR can be under /var which is symlinked to + -- /private/var) + -- 2. Remove any double separators (on macOS TMPDIR can end in a trailing / which absolute doesn't remove, this should + -- be coverted by https://github.com/nvim-lua/plenary.nvim/issues/330). + local temp_dir = vim.fn.resolve( + Path:new( + vim.fn.fnamemodify(vim.fn.tempname(), ":h"), + string.format("neo-tree-test-%s", vim.fn.rand()) + ):absolute() + ) + vim.fn.mkdir(temp_dir, "p") + return temp_dir +end + +function fs.create_dir(path) + local abspath = Path:new(path):absolute() + vim.fn.mkdir(abspath, "p") +end + +function fs.remove_dir(dir, recursive) + if vim.fn.isdirectory(dir) == 1 then + return vim.fn.delete(dir, recursive and "rf" or "d") == 0 + end + return false +end + +function fs.write_file(path, content) + local abspath = Path:new(path):absolute() + fs.create_dir(vim.fn.fnamemodify(abspath, ":h")) + vim.fn.writefile(content or {}, abspath) +end + +function fs.create_fs_tree(fs_tree) + local function create_items(items, basedir, relative_root_path) + relative_root_path = relative_root_path or "." + + for _, item in ipairs(items) do + local relative_path = relative_root_path .. "/" .. item.name + + -- create lookups + fs_tree.lookup[relative_path] = item + if item.id then + fs_tree.lookup[item.id] = item + end + + -- create actual files and directories + if item.type == "dir" then + item.abspath = Path:new(basedir, item.name):absolute() + fs.create_dir(item.abspath) + if item.items then + create_items(item.items, item.abspath, relative_path) + end + elseif item.type == "file" then + item.abspath = Path:new(basedir, item.name):absolute() + fs.write_file(item.abspath) + end + end + end + + create_items(fs_tree.items, fs_tree.abspath) + + return fs_tree +end + +function fs.init_test(fs_tree) + fs_tree.lookup = {} + if not fs_tree.abspath then + fs_tree.abspath = fs.create_temp_dir() + end + + local function setup() + fs.remove_dir(fs_tree.abspath, true) + fs.create_fs_tree(fs_tree) + vim.cmd("tcd " .. fs_tree.abspath) + end + + local function teardown() + fs.remove_dir(fs_tree.abspath, true) + end + + return { + fs_tree = fs_tree, + setup = setup, + teardown = teardown, + } +end + +return fs diff --git a/bundle/neo-tree.nvim/tests/utils/init.lua b/bundle/neo-tree.nvim/tests/utils/init.lua new file mode 100644 index 000000000..72f18f94d --- /dev/null +++ b/bundle/neo-tree.nvim/tests/utils/init.lua @@ -0,0 +1,183 @@ +local mod = { + fs = require("tests.utils.fs"), +} + +function mod.clear_environment() + -- Create fresh window + vim.cmd("top new | wincmd o") + local keepbufnr = vim.api.nvim_get_current_buf() + -- Clear ALL neo-tree state + require("neo-tree.sources.manager")._clear_state() + -- Cleanup any remaining buffers + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + if bufnr ~= keepbufnr then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + assert(#vim.api.nvim_tabpage_list_wins(0) == 1, "Failed to properly clear tab") + assert(#vim.api.nvim_list_bufs() == 1, "Failed to properly clear buffers") +end + +mod.editfile = function(testfile) + vim.cmd("e " .. testfile) + assert.are.same( + vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ":p"), + vim.fn.fnamemodify(testfile, ":p") + ) +end + +function mod.eq(...) + return assert.are.same(...) +end + +function mod.neq(...) + return assert["not"].are.same(...) +end + +---@param keys string +---@param mode? string +function mod.feedkeys(keys, mode) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), mode or "x", true) +end + +---@param tbl table +---@param keys string[] +function mod.tbl_pick(tbl, keys) + if not keys or #keys == 0 then + return tbl + end + + local new_tbl = {} + for _, key in ipairs(keys) do + new_tbl[key] = tbl[key] + end + return new_tbl +end + +local orig_require = _G.require +-- can be used to enable/disable package +-- for specific tests +function mod.get_require_switch() + local disabled_packages = {} + + local function fake_require(name) + if vim.tbl_contains(disabled_packages, name) then + return error("test: package disabled") + end + + return orig_require(name) + end + + return { + disable_package = function(name) + _G.require = fake_require + package.loaded[name] = nil + table.insert(disabled_packages, name) + end, + enable_package = function(name) + _G.require = fake_require + disabled_packages = vim.tbl_filter(function(package_name) + return package_name ~= name + end, disabled_packages) + end, + restore = function() + disabled_packages = {} + _G.require = orig_require + end, + } +end + +---@param bufnr number +---@param lines string[] +---@param linenr_start? integer (1-indexed) +---@param linenr_end? integer (1-indexed, inclusive) +function mod.assert_buf_lines(bufnr, lines, linenr_start, linenr_end) + mod.eq( + vim.api.nvim_buf_get_lines( + bufnr, + linenr_start and linenr_start - 1 or 0, + linenr_end or -1, + false + ), + lines + ) +end + +---@param bufnr number +---@param ns_id integer +---@param linenr integer (1-indexed) +---@param byte_start? integer (0-indexed) +---@param byte_end? integer (0-indexed, inclusive) +function mod.get_line_extmarks(bufnr, ns_id, linenr, byte_start, byte_end) + return vim.api.nvim_buf_get_extmarks( + bufnr, + ns_id, + { linenr - 1, byte_start or 0 }, + { linenr - 1, byte_end and byte_end + 1 or -1 }, + { details = true } + ) +end + +---@param bufnr number +---@param ns_id integer +---@param linenr integer (1-indexed) +---@param text string +---@return table[] +---@return { byte_start: integer, byte_end: integer } info (byte range: 0-indexed, inclusive) +function mod.get_text_extmarks(bufnr, ns_id, linenr, text) + local line = vim.api.nvim_buf_get_lines(bufnr, linenr - 1, linenr, false)[1] + + local byte_start = string.find(line, text) -- 1-indexed + byte_start = byte_start - 1 -- 0-indexed + local byte_end = byte_start + #text - 1 -- inclusive + + local extmarks = vim.api.nvim_buf_get_extmarks( + bufnr, + ns_id, + { linenr - 1, byte_start }, + { linenr - 1, byte_end }, + { details = true } + ) + + return extmarks, { byte_start = byte_start, byte_end = byte_end } +end + +---@param extmark table +---@param linenr number (1-indexed) +---@param text string +---@param hl_group string +function mod.assert_extmark(extmark, linenr, text, hl_group) + mod.eq(extmark[2], linenr - 1) + + if text then + local start_col = extmark[3] + mod.eq(extmark[4].end_col - start_col, #text) + end + + mod.eq(mod.tbl_pick(extmark[4], { "end_row", "hl_group" }), { + end_row = linenr - 1, + hl_group = hl_group, + }) +end + +---@param bufnr number +---@param ns_id integer +---@param linenr integer (1-indexed) +---@param text string +---@param hl_group string +function mod.assert_highlight(bufnr, ns_id, linenr, text, hl_group) + local extmarks, info = mod.get_text_extmarks(bufnr, ns_id, linenr, text) + + mod.eq(#extmarks, 1) + mod.eq(extmarks[1][3], info.byte_start) + mod.assert_extmark(extmarks[1], linenr, text, hl_group) +end + +---@param callback fun(): boolean +---@param options? { interval?: integer, timeout?: integer } +function mod.wait_for(callback, options) + options = options or {} + vim.wait(options.timeout or 1000, callback, options.interval or 100) +end + +return mod diff --git a/bundle/neo-tree.nvim/tests/utils/verify.lua b/bundle/neo-tree.nvim/tests/utils/verify.lua new file mode 100644 index 000000000..64589afda --- /dev/null +++ b/bundle/neo-tree.nvim/tests/utils/verify.lua @@ -0,0 +1,106 @@ +local verify = {} + +verify.eventually = function(timeout, assertfunc, failmsg, ...) + local success, args = false, { ... } + vim.wait(timeout or 1000, function() + success = assertfunc(unpack(args)) + return success + end) + assert(success, failmsg) +end + +verify.after = function(timeout, assertfunc, failmsg) + vim.wait(timeout, function() + return false + end) + assert(assertfunc(), failmsg) +end + +verify.bufnr_is = function(bufnr, timeout) + verify.eventually(timeout or 500, function() + return bufnr == vim.api.nvim_get_current_buf() + end, string.format("Current buffer is expected to be '%s' but is not", bufnr)) +end + +verify.bufnr_is_not = function(bufnr, timeout) + verify.eventually(timeout or 500, function() + return bufnr ~= vim.api.nvim_get_current_buf() + end, string.format("Current buffer is '%s' when expected to not be", bufnr)) +end + +verify.buf_name_endswith = function(buf_name, timeout) + verify.eventually( + timeout or 500, + function() + if buf_name == "" then + return true + end + local n = vim.api.nvim_buf_get_name(0) + if n:sub(-#buf_name) == buf_name then + return true + else + return false + end + end, + string.format("Current buffer name is expected to be end with '%s' but it does not", buf_name) + ) +end + +verify.buf_name_is = function(buf_name, timeout) + verify.eventually(timeout or 500, function() + return buf_name == vim.api.nvim_buf_get_name(0) + end, string.format("Current buffer name is expected to be '%s' but is not", buf_name)) +end + +verify.tree_focused = function(timeout) + verify.eventually(timeout or 1000, function() + return vim.api.nvim_buf_get_option(0, "filetype") == "neo-tree" + end, "Current buffer is not a 'neo-tree' filetype") +end + +verify.tree_node_is = function(source_name, expected_node_id, winid, timeout) + verify.eventually(timeout or 500, function() + local state = require("neo-tree.sources.manager").get_state(source_name, nil, winid) + if not state.tree then + return false + end + local success, node = pcall(state.tree.get_node, state.tree) + if not success then + return false + end + if not node then + return false + end + local node_id = node:get_id() + if node_id == expected_node_id then + return true + end + return false + end, string.format("Tree node '%s' not focused", expected_node_id)) +end + +verify.filesystem_tree_node_is = function(expected_node_id, winid, timeout) + verify.tree_node_is("filesystem", expected_node_id, winid, timeout) +end + +verify.buffers_tree_node_is = function(expected_node_id, winid, timeout) + verify.tree_node_is("buffers", expected_node_id, winid, timeout) +end + +verify.git_status_tree_node_is = function(expected_node_id, winid, timeout) + verify.tree_node_is("git_status", expected_node_id, winid, timeout) +end + +verify.window_handle_is = function(winid, timeout) + verify.eventually(timeout or 500, function() + return winid == vim.api.nvim_get_current_win() + end, string.format("Current window handle is expected to be '%s' but is not", winid)) +end + +verify.window_handle_is_not = function(winid, timeout) + verify.eventually(timeout or 500, function() + return winid ~= vim.api.nvim_get_current_win() + end, string.format("Current window handle is not expected to be '%s' but it is", winid)) +end + +return verify diff --git a/bundle/nui.nvim/.codecov.yml b/bundle/nui.nvim/.codecov.yml new file mode 100644 index 000000000..7c4fa592a --- /dev/null +++ b/bundle/nui.nvim/.codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + informational: true + only_pulls: true + patch: + default: + informational: true + only_pulls: true diff --git a/bundle/nui.nvim/.github/workflows/ci.yml b/bundle/nui.nvim/.github/workflows/ci.yml new file mode 100644 index 000000000..cabbeb55b --- /dev/null +++ b/bundle/nui.nvim/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: luacheck + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Get Cache Key + id: luver-cache-key + env: + CI_RUNNER_OS: ${{ runner.os }} + CI_SECRETS_CACHE_VERSION: ${{ secrets.CACHE_VERSION }} + run: | + echo "::set-output name=value::${CI_RUNNER_OS}-luver-${CI_SECRETS_CACHE_VERSION}-$(date -u +%Y-%m-%d)" + shell: bash + - name: Setup Cache + uses: actions/cache@v2 + with: + path: ~/.local/share/luver + key: ${{ steps.luver-cache-key.outputs.value }} + - name: Setup Lua + uses: MunifTanjim/luver-action@v1 + with: + default: 5.1.5 + lua_versions: 5.1.5 + luarocks_versions: 5.1.5:3.8.0 + - name: Setup luacheck + run: | + luarocks install luacheck + - name: Lint + run: ./scripts/lint.sh --no-cache + + format: + name: stylua + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Check Format + uses: JohnnyMorganz/stylua-action@1.0.0 + with: + version: 0.13.1 + token: ${{ secrets.GITHUB_TOKEN }} + args: --color always --check lua/ + + test: + name: test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Get Cache Key + id: luver-cache-key + env: + CI_RUNNER_OS: ${{ runner.os }} + CI_SECRETS_CACHE_VERSION: ${{ secrets.CACHE_VERSION }} + run: | + echo "::set-output name=value::${CI_RUNNER_OS}-luver-${CI_SECRETS_CACHE_VERSION}-$(date -u +%Y-%m-%d)" + shell: bash + - name: Setup Cache + uses: actions/cache@v2 + with: + path: ~/.local/share/luver + key: ${{ steps.luver-cache-key.outputs.value }} + - name: Setup Lua + uses: MunifTanjim/luver-action@v1 + with: + default: 5.1.5 + lua_versions: 5.1.5 + luarocks_versions: 5.1.5:3.8.0 + - name: Setup luacov + run: | + luarocks install luacov + - name: Setup Neovim + uses: MunifTanjim/setup-neovim-action@v1 + with: + tag: nightly + - name: Run Tests + run: | + nvim --version + ./scripts/test.sh + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + verbose: true + + release: + name: release + if: ${{ github.ref == 'refs/heads/main' }} + needs: + - lint + - format + - test + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + pull-requests: write + steps: + - uses: google-github-actions/release-please-action@v3 + id: release + with: + release-type: simple + package-name: nui.nvim + bump-minor-pre-major: true + pull-request-title-pattern: "chore: release ${version}" + include-v-in-tag: false + - name: Trigger Publish + if: ${{ steps.release.outputs.release_created }} + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ steps.release.outputs.tag_name }} + run: | + gh workflow run --repo ${GITHUB_REPOSITORY} publish.yml -f version=${TAG_NAME} diff --git a/bundle/nui.nvim/.github/workflows/publish.yml b/bundle/nui.nvim/.github/workflows/publish.yml new file mode 100644 index 000000000..e1b4f7e08 --- /dev/null +++ b/bundle/nui.nvim/.github/workflows/publish.yml @@ -0,0 +1,33 @@ +name: Publish + +on: + push: + tags: + - "[0-1].[0-9]+.[0-9]+" + workflow_dispatch: + inputs: + version: + description: Version to publish + required: false + type: string + force: + description: Force publish + required: false + default: false + type: boolean + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: LuaRocks Publish + uses: MunifTanjim/luarocks-publish-action@v1 + with: + lua_version: 5.1.5 + luarocks_version: 3.9.1 + version: ${{ inputs.version }} + api_key: ${{ secrets.LUAROCKS_API_KEY }} + force: ${{ inputs.force }} diff --git a/bundle/nui.nvim/.gitignore b/bundle/nui.nvim/.gitignore new file mode 100644 index 000000000..65f3b99fc --- /dev/null +++ b/bundle/nui.nvim/.gitignore @@ -0,0 +1,5 @@ +.luacheckcache + +luacov.*.out + +.testcache diff --git a/bundle/nui.nvim/.luacheckrc b/bundle/nui.nvim/.luacheckrc new file mode 100644 index 000000000..f4c8623ba --- /dev/null +++ b/bundle/nui.nvim/.luacheckrc @@ -0,0 +1,16 @@ +cache = ".luacheckcache" +-- https://luacheck.readthedocs.io/en/stable/warnings.html +ignore = { + "211/_.*", + "212/_.*", + "213/_.*", +} +include_files = { "*.luacheckrc", "lua/**/*.lua", "tests/**/*.lua" } +globals = { "vim" } +std = "luajit" + +files["tests/helpers/**/*.lua"] = { + read_globals = { "assert", "describe" }, +} + +-- vim: set filetype=lua : diff --git a/bundle/nui.nvim/.luacov b/bundle/nui.nvim/.luacov new file mode 100644 index 000000000..4bcfb326a --- /dev/null +++ b/bundle/nui.nvim/.luacov @@ -0,0 +1,3 @@ +include = { + "lua%/nui", +} diff --git a/bundle/nui.nvim/.stylua.toml b/bundle/nui.nvim/.stylua.toml new file mode 100644 index 000000000..0fd4cb51e --- /dev/null +++ b/bundle/nui.nvim/.stylua.toml @@ -0,0 +1,6 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +no_call_parentheses = false diff --git a/bundle/nui.nvim/CHANGELOG.md b/bundle/nui.nvim/CHANGELOG.md new file mode 100644 index 000000000..eae9ff7c0 --- /dev/null +++ b/bundle/nui.nvim/CHANGELOG.md @@ -0,0 +1,234 @@ +# Changelog + +## 0.1.0 (2023-05-27) + + +### ⚠ BREAKING CHANGES + +* **line:** change parameter order for methods. +* **text:** change parameter order for methods. + +### Features + +* accept multiple keys for keymap ([4998347](https://github.com/MunifTanjim/nui.nvim/commit/4998347f02fde115ad0c023b90be9c5654834635)) +* add internal utils._.calculate_gap_width ([90d7285](https://github.com/MunifTanjim/nui.nvim/commit/90d7285c182b396c21ba69684068fd7d5d6eba79)) +* add util to clear namespace for buffer ([6c63bf5](https://github.com/MunifTanjim/nui.nvim/commit/6c63bf5fd51076455d1b04f5c09d9841ab081263)) +* **bar:** add lower-level `core.add_highlight` function ([20385a6](https://github.com/MunifTanjim/nui.nvim/commit/20385a698e8a5dd98ee7e63f16b700a10b921098)) +* **bar:** add some lower-level `core.add_*` functions ([5d1ca66](https://github.com/MunifTanjim/nui.nvim/commit/5d1ca66829d8fac9965cd18fcc2cd9aa49ba1ea5)) +* **bar:** initial implementation ([35758e9](https://github.com/MunifTanjim/nui.nvim/commit/35758e946a64376e0e9625a27469410b3d1f9223)) +* **bar:** remove module ([0dc148c](https://github.com/MunifTanjim/nui.nvim/commit/0dc148c6ec06577fcf06cbab3b7dac96d48ba6be)) +* **input:** add component Input ([7307c94](https://github.com/MunifTanjim/nui.nvim/commit/7307c94a7a0954f1c717e094c39772f28bb9d13f)) +* **input:** move internal default_value and prompt prop ([595a2ea](https://github.com/MunifTanjim/nui.nvim/commit/595a2ea90f7d31bc23386ba67939117d45771035)) +* **input:** support nui.text for options.prompt ([c380563](https://github.com/MunifTanjim/nui.nvim/commit/c38056355f5a72c7a1a005f7125afd62ed7b2083)) +* **layout:** add method layout:update ([3f611a6](https://github.com/MunifTanjim/nui.nvim/commit/3f611a674254370d6d73f9a35a3eedbc8d07ee3b)) +* **layout:** add some util for size and position ([cc3b970](https://github.com/MunifTanjim/nui.nvim/commit/cc3b970c1537de9562f82e5fa6aa126a629242d1)) +* **layout:** feature guard lua autocmd api usage ([3dc6b89](https://github.com/MunifTanjim/nui.nvim/commit/3dc6b89bda8f1616a2638727eeb75a4a770e1c21)) +* **layout:** initial implementation ([716c3f9](https://github.com/MunifTanjim/nui.nvim/commit/716c3f9d857b62a086b4f9123d0d34d1b7733922)) +* **layout:** introduce layout type ([a5fd005](https://github.com/MunifTanjim/nui.nvim/commit/a5fd005263d238d2fbd6ee335e06139645f11fa9)) +* **layout:** introduce split layout ([042cceb](https://github.com/MunifTanjim/nui.nvim/commit/042cceb497cc4cfa3ae735a5e7bc01b4b6f19ef1)) +* **layout:** make window transparent ([2d33512](https://github.com/MunifTanjim/nui.nvim/commit/2d33512836cb603d58cb538e716a02c44547c6ab)) +* **layout:** re-use windows for unchanged split layout box ([71ddaaf](https://github.com/MunifTanjim/nui.nvim/commit/71ddaafadfd8b3c21f809ea26fb4fe2a110bbf54)) +* **layout:** support :show and :hide for float layout ([f572782](https://github.com/MunifTanjim/nui.nvim/commit/f572782bd8cc6b6b8671566b09146c4c09f20ae2)) +* **layout:** support container component ([5bc7376](https://github.com/MunifTanjim/nui.nvim/commit/5bc737602e231111c44458c84e3eca05393f355f)) +* **layout:** support o.grow factor for layout.box ([796dc82](https://github.com/MunifTanjim/nui.nvim/commit/796dc8293be59acc0c0d53b99e7dca7c32db4413)) +* **layout:** support o.grow for layout.box ([2c6bac9](https://github.com/MunifTanjim/nui.nvim/commit/2c6bac942e3af35eb42165c923c0d3dc60a46430)) +* **layout:** throw for empty box at init ([6e872b3](https://github.com/MunifTanjim/nui.nvim/commit/6e872b3bf28aaf52fd1b831ea0690aea205d6464)) +* **layout:** tweak component wire up ([eed888e](https://github.com/MunifTanjim/nui.nvim/commit/eed888e47fa91980ce295dd347b213a102200b5a)) +* **layout:** use backported autocmd methods ([4e21085](https://github.com/MunifTanjim/nui.nvim/commit/4e21085cd1b6be12da7ff0e7168e9100c51c363a)) +* **layout:** wire up float layout components ([3aa617d](https://github.com/MunifTanjim/nui.nvim/commit/3aa617d9054e052cde68cd3141db4396af93a9cb)) +* **line:** accept initial nui.text objects ([2f44cc9](https://github.com/MunifTanjim/nui.nvim/commit/2f44cc941cf1a129b6b6069a7489672b24cf0015)) +* **line:** accept NuiText object as param for line:append() method ([34fd4bf](https://github.com/MunifTanjim/nui.nvim/commit/34fd4bfde84ff4b735a98dfb3508280d39e520f6)) +* **line:** add method :width ([80122e5](https://github.com/MunifTanjim/nui.nvim/commit/80122e542fcebc361c4ca585be40a071c6e360be)) +* **line:** add nui.line block ([1333fd0](https://github.com/MunifTanjim/nui.nvim/commit/1333fd07c57310d1421dcb337a21fbddb20d3c84)) +* **line:** make ns_id required, update method signature ([5695bde](https://github.com/MunifTanjim/nui.nvim/commit/5695bde7ac9b8bcbb1df237a05169bad99458090)) +* **line:** support nui.line in method :append ([401a69f](https://github.com/MunifTanjim/nui.nvim/commit/401a69f27ede2d5a9a725f4f45758708d3b72d09)) +* make components extendable ([c75976e](https://github.com/MunifTanjim/nui.nvim/commit/c75976e823085218aeef4f5312a8c88e7d333358)) +* **menu:** add component Menu ([dca0630](https://github.com/MunifTanjim/nui.nvim/commit/dca0630b0d7ab5dfcabaa70284c94c2e133b8200)) +* **menu:** expose .tree ([d12a697](https://github.com/MunifTanjim/nui.nvim/commit/d12a6977846b2fa978bff89b439e509320854e10)) +* **menu:** improve menu separator implementation ([0e05425](https://github.com/MunifTanjim/nui.nvim/commit/0e0542505369861fc22f6bff3ad2976bdef6d8f3)) +* **menu:** move internal props ([0373a94](https://github.com/MunifTanjim/nui.nvim/commit/0373a94bc79725726aadcc1e0e26de159e3152e1)) +* **menu:** pass self to on_change callback ([4438c5e](https://github.com/MunifTanjim/nui.nvim/commit/4438c5e53f8a5c834569309a5d3e276e6bf1abe9)) +* **menu:** rename method menu:init to menu:new ([2825c3d](https://github.com/MunifTanjim/nui.nvim/commit/2825c3d60438bbed9c04f18f030ab3505249e8a7)) +* **menu:** simplify automatic width calculation ([54cbaf5](https://github.com/MunifTanjim/nui.nvim/commit/54cbaf5d227cfedbb939d16b405116e4cea6e3fb)) +* **menu:** support arbritary props for Menu.item ([77cefa6](https://github.com/MunifTanjim/nui.nvim/commit/77cefa67df7f282db20aa9afb5925aff79455dc2)) +* **menu:** support nui.line for Menu.item ([51cbd0c](https://github.com/MunifTanjim/nui.nvim/commit/51cbd0ccc9410e317a947eea1e99966226a5f8b5)) +* **menu:** support nui.text as separator char ([3029554](https://github.com/MunifTanjim/nui.nvim/commit/30295541c1bda2ab171ab7b684f790be3fbb60a7)) +* **menu:** support nui.text for Menu.item ([13e557d](https://github.com/MunifTanjim/nui.nvim/commit/13e557d045b62efc6da87bdcc77503b5adf1db21)) +* **menu:** support options.on_change ([db06fed](https://github.com/MunifTanjim/nui.nvim/commit/db06feddf324d4c6c8763fe9fca43b9140de84a9)) +* **object:** add helper functions ([9531977](https://github.com/MunifTanjim/nui.nvim/commit/95319774b31558479c71e9b190870aae7bd8e49f)) +* **object:** initial implementation ([194837f](https://github.com/MunifTanjim/nui.nvim/commit/194837ffc4a9c77dfcc18809c7e43a02b4d11e03)) +* **popup:** add method .border:set_highlight ([acb72b1](https://github.com/MunifTanjim/nui.nvim/commit/acb72b150c7fdf01331b08460ec07fe7a81029b6)) +* **popup:** add method popup:set_layout(config) ([006711f](https://github.com/MunifTanjim/nui.nvim/commit/006711f3ab4e8626f47b2b903759a9ac0e232fbd)) +* **popup:** add method popup:set_position(position, relative) ([7ea1a6b](https://github.com/MunifTanjim/nui.nvim/commit/7ea1a6b910b1b33fceb33068df8f31e14ccceb1e)) +* **popup:** add method popup:set_size(size) ([07b6e9a](https://github.com/MunifTanjim/nui.nvim/commit/07b6e9a90b58af8ca9c09f3695aa14603c947b61)) +* **popup:** add method popup:unmap ([46bbf33](https://github.com/MunifTanjim/nui.nvim/commit/46bbf336e9068c73c4e810cb93c7a1f8197ebbc3)) +* **popup:** add method popup.border:set_text(...) ([b82a5d3](https://github.com/MunifTanjim/nui.nvim/commit/b82a5d3bda43b5cd9b9dca39b782e86d2735933f)) +* **popup:** add methods popup:hide() and popup:show() ([b3c706d](https://github.com/MunifTanjim/nui.nvim/commit/b3c706d4c30bd5cd8b05d10a2c8bf8fcd1f5cf7a)) +* **popup:** add methods popup:on(...) and popup:off(...) ([0eb57a2](https://github.com/MunifTanjim/nui.nvim/commit/0eb57a2bdd565dfad09e4bda4293ef11c17d741f)) +* **popup:** add option 'focusable' ([8339965](https://github.com/MunifTanjim/nui.nvim/commit/8339965e991ad54e6606ea22213996521701e293)) +* **popup:** add option padding ([596cd77](https://github.com/MunifTanjim/nui.nvim/commit/596cd77a875eef1c6629e12074139e2ca0033e38)) +* **popup:** add options 'buf_options' and 'win_options' ([163d99a](https://github.com/MunifTanjim/nui.nvim/commit/163d99a3fb9fe9a630c96bbf6159973885c3caf3)) +* **popup:** add options.ns_id ([6f165aa](https://github.com/MunifTanjim/nui.nvim/commit/6f165aad4d2ac5a5a279a695ed247ec954cdcc4b)) +* **popup:** add type annotation for bufnr,winid,ns_id ([c2d1f73](https://github.com/MunifTanjim/nui.nvim/commit/c2d1f73eb0ab02e90e8316a6e70158d2eec72153)) +* **popup:** add type annotation for win_config ([f8ccc5c](https://github.com/MunifTanjim/nui.nvim/commit/f8ccc5cf8e8aec7ff34572b80e7b5d92b2d07556)) +* **popup:** allow layout refresh when container size changes ([ee8d315](https://github.com/MunifTanjim/nui.nvim/commit/ee8d315456691fc1d05dcec465d95f8aec902541)) +* **popup:** change behavior of padding ([9f29df4](https://github.com/MunifTanjim/nui.nvim/commit/9f29df4153da1483ad784a473f5bbcf02fcbbe3b)) +* **popup:** clear namespace object on unmount ([58e06b0](https://github.com/MunifTanjim/nui.nvim/commit/58e06b0175cf22672d96a522343ec6ce017ba54c)) +* **popup:** create buffer on initialization ([6b1deda](https://github.com/MunifTanjim/nui.nvim/commit/6b1deda411b96f0d694dead7cc12ab9db1dd9b65)) +* **popup:** default border.text hl to FloatTitle ([4eaec2a](https://github.com/MunifTanjim/nui.nvim/commit/4eaec2ac66af2ca6ddddd3f665ad0909b90ae36a)) +* **popup:** feature guard lua autocmd api usage ([6028584](https://github.com/MunifTanjim/nui.nvim/commit/60285847a4df99c2ab0ece3020eb930c81b09c41)) +* **popup:** improve border highlight implementation ([2f58c40](https://github.com/MunifTanjim/nui.nvim/commit/2f58c406eb9cb5cedf5d82f9ce7c4f6efc418550)) +* **popup:** improve cleanup ([220d4a4](https://github.com/MunifTanjim/nui.nvim/commit/220d4a4bbaa8f7dc80c0aa37e8377c7c150c6384)) +* **popup:** merge internal position_meta into position ([bc2fc9c](https://github.com/MunifTanjim/nui.nvim/commit/bc2fc9c1b7b7241c4a0f34597f8ddc11c5f90844)) +* **popup:** move internal buf_options and win_options ([3148908](https://github.com/MunifTanjim/nui.nvim/commit/31489084b7d3363c9a6f8f4d435374a25f4f636b)) +* **popup:** move internal loading and mounted state ([4dc3214](https://github.com/MunifTanjim/nui.nvim/commit/4dc321448faaad171f0aab07d092a0e626fb1cbb)) +* **popup:** move internal position state ([d229bbb](https://github.com/MunifTanjim/nui.nvim/commit/d229bbb4846bd4268ca1f6f67543294237ee0029)) +* **popup:** move internal position_meta state ([cbbbe90](https://github.com/MunifTanjim/nui.nvim/commit/cbbbe90213091a462e71a1f102e0927ac11bac8a)) +* **popup:** move internal size prop ([0a2fced](https://github.com/MunifTanjim/nui.nvim/commit/0a2fcedb97673ba478e26a0f59edbca950d15d31)) +* **popup:** move internal win_enter prop ([f7736c9](https://github.com/MunifTanjim/nui.nvim/commit/f7736c90db395adc276519bd871bcec088bed908)) +* **popup:** remove automatic cleanup ([06b48cf](https://github.com/MunifTanjim/nui.nvim/commit/06b48cf645971e1e1d598a083b98733890a82302)) +* **popup:** remove method popup:on(...) ([c24b131](https://github.com/MunifTanjim/nui.nvim/commit/c24b13195c3e8c7c42d64736d96ded0ce7cb13ed)) +* **popup:** rename method :set_layout to :update_layout ([90f59b0](https://github.com/MunifTanjim/nui.nvim/commit/90f59b035565e549e467fc414f1178d30c074ce9)) +* **popup:** rename window to popup ([7097509](https://github.com/MunifTanjim/nui.nvim/commit/7097509b4b0fd21d870cea19c51389043c408449)) +* **popup:** rework border internals ([8a776a2](https://github.com/MunifTanjim/nui.nvim/commit/8a776a2033220b16fa032b9d3590c75a178435ec)) +* **popup:** simplify border highlight mechanism ([0ec30d9](https://github.com/MunifTanjim/nui.nvim/commit/0ec30d912c473139832d5c0bfd8ccfa675a48974)) +* **popup:** simplify border.text ([674305a](https://github.com/MunifTanjim/nui.nvim/commit/674305a0cb1df2b2c0f51ade7081235755e93643)) +* **popup:** support nui.text for simple border ([5f5a2e5](https://github.com/MunifTanjim/nui.nvim/commit/5f5a2e5284a08f930098383a6d0a00b861fc58d2)) +* **popup:** support NuiText as border.text ([1248c67](https://github.com/MunifTanjim/nui.nvim/commit/1248c674a4a632ea7d45a76b1255df997379e116)) +* **popup:** support option 'anchor' ([a86c733](https://github.com/MunifTanjim/nui.nvim/commit/a86c733e7d30596d80927b5eaf6869aa9a3d30af)) +* **popup:** support set_size when unmounted ([fa86f85](https://github.com/MunifTanjim/nui.nvim/commit/fa86f8539373e1b76b93f95dfa36a5793ed4fe35)) +* **popup:** support unmanaged buffer ([335415a](https://github.com/MunifTanjim/nui.nvim/commit/335415af52ea23d07433aa1f72f7c0d56c219316)) +* **popup:** use backported autocmd methods ([372369d](https://github.com/MunifTanjim/nui.nvim/commit/372369dea4c059f831e540e34b9a49bf183c245b)) +* **split:** add component Split ([bcb7382](https://github.com/MunifTanjim/nui.nvim/commit/bcb73828d54169f5ae9d141bca05fecb6aec5ec5)) +* **split:** add method :update_layout ([32f44a6](https://github.com/MunifTanjim/nui.nvim/commit/32f44a610691ff2e22e60a8040801e047112806f)) +* **split:** add method split:unmap ([e0444e0](https://github.com/MunifTanjim/nui.nvim/commit/e0444e020fd3ed336e2b6ba998ff7903ddb68ddd)) +* **split:** create buffer on initialization ([0e36b78](https://github.com/MunifTanjim/nui.nvim/commit/0e36b78b836200ef83ef723c6a0ebb753ac8a11e)) +* **split:** feature guard lua autocmd api usage ([d14daab](https://github.com/MunifTanjim/nui.nvim/commit/d14daab6710d1ebd7ec868f15bddde2bbd2c186f)) +* **split:** improve cleanup ([36b0649](https://github.com/MunifTanjim/nui.nvim/commit/36b0649d7df3d1899d5aed8b27d22159cf058a2e)) +* **split:** move internal buf_options and win_options ([6c9a3ee](https://github.com/MunifTanjim/nui.nvim/commit/6c9a3ee9fdb2c7a48cf13d9c82ee56822c5bfdb8)) +* **split:** move internal loading and mounted state ([653199c](https://github.com/MunifTanjim/nui.nvim/commit/653199c635ad56c1e313e0890c6a72da8d3ee3bd)) +* **split:** move internal props ([25e51eb](https://github.com/MunifTanjim/nui.nvim/commit/25e51eba14cdf2d445d0c0efde78d808741483cb)) +* **split:** set buffer after .winid is set ([35091ca](https://github.com/MunifTanjim/nui.nvim/commit/35091ca0f8c766ea3542508f3ab9217a746cc5a0)) +* **split:** store id internally ([96ef1cb](https://github.com/MunifTanjim/nui.nvim/commit/96ef1cb4e3c830dc79e18acac209ea5f7eb78829)) +* **split:** support o.enter ([b75e2e6](https://github.com/MunifTanjim/nui.nvim/commit/b75e2e6d2a86a1105a756b894385d5fe836c3821)) +* **split:** support o.ns_id ([28cafab](https://github.com/MunifTanjim/nui.nvim/commit/28cafab82f5d5ffc4321b9f82bae7b38951434d1)) +* **split:** support o.relative.winid ([82851af](https://github.com/MunifTanjim/nui.nvim/commit/82851af021d651bb6bf6da0991c79f4c529ff37e)) +* **split:** tweak split size handling for new window ([b681ab2](https://github.com/MunifTanjim/nui.nvim/commit/b681ab2a8a8750b37dcece4e87105a5671c39745)) +* **split:** use backported autocmd methods ([6bd1d8a](https://github.com/MunifTanjim/nui.nvim/commit/6bd1d8af326c654a8b9d122c32a62fed5e55fe89)) +* **text:** add method :new ([4ad7811](https://github.com/MunifTanjim/nui.nvim/commit/4ad781109a44bc00fa7d1208576f4086add27ad3)) +* **text:** add method text:set(content, highlight?) ([eaf4844](https://github.com/MunifTanjim/nui.nvim/commit/eaf4844e9e84994705acc0347673a6b15e7c030f)) +* **text:** add nui.text block ([a6df800](https://github.com/MunifTanjim/nui.nvim/commit/a6df800df514c2f0a9dd88d8db267ec5b8c8d68d)) +* **text:** change highlight table key group->hl_group ([a8aaca1](https://github.com/MunifTanjim/nui.nvim/commit/a8aaca1578dff3504e78538b477910de344af0f8)) +* **text:** make ns_id required, update method signature ([b63d199](https://github.com/MunifTanjim/nui.nvim/commit/b63d199ddfdc89457ae401ed0a5659fa1055c37c)) +* **text:** preserve own extmark id ([26622d1](https://github.com/MunifTanjim/nui.nvim/commit/26622d147762f2212bf30e0792df1d0164a73cd9)) +* **text:** remove method :new ([18a0390](https://github.com/MunifTanjim/nui.nvim/commit/18a0390d5caca31b310af41787e5ddd407c68783)) +* **text:** return self from method text:set ([c5971ed](https://github.com/MunifTanjim/nui.nvim/commit/c5971ed28773cf9b6ce4ed8bfbc20266aa05b2a1)) +* **text:** support cloning and use extmarks ([281d453](https://github.com/MunifTanjim/nui.nvim/commit/281d4535d046b1dd09d45bca8b0739bb16461b75)) +* **text:** support extmark override when cloning ([878dfaf](https://github.com/MunifTanjim/nui.nvim/commit/878dfaf85fa19ff6811f19f1dad0bbfda77d5bbf)) +* **tree:** add method node:get_child_ids ([bc05620](https://github.com/MunifTanjim/nui.nvim/commit/bc056204ff06b205dd3804474fc155180e704d47)) +* **tree:** add method tree:get_nodes ([f85aedc](https://github.com/MunifTanjim/nui.nvim/commit/f85aedc1378acb375e4da0ec2220c1df0929e843)) +* **tree:** add method tree:set_nodes(nodes, parent_id?) ([b25fab5](https://github.com/MunifTanjim/nui.nvim/commit/b25fab59d997cd9793bf7f42a1e41b9b7684d987)) +* **tree:** add nui.tree block ([3bdfa78](https://github.com/MunifTanjim/nui.nvim/commit/3bdfa780fdba053358b85fd1f276052ae1455b61)) +* **tree:** add o.bufnr and deprecate o.winid ([ce4869f](https://github.com/MunifTanjim/nui.nvim/commit/ce4869f97e4f3d4f9eb64d021fff775684ae8859)) +* **tree:** clear namespace before render ([1f66cc7](https://github.com/MunifTanjim/nui.nvim/commit/1f66cc794bb6cad23f97b71e099cc32dd63c0614)) +* **tree:** make node:has_children method work before init ([96f600b](https://github.com/MunifTanjim/nui.nvim/commit/96f600b58f128bde4a78a56c0a64d55664a43955)) +* **tree:** move internal buf_options and win_options ([3a7c0c4](https://github.com/MunifTanjim/nui.nvim/commit/3a7c0c48b279ee0495c5ec61ca312fddd052195c)) +* **tree:** move internal get_node_id and prepare_node functions ([1b9e046](https://github.com/MunifTanjim/nui.nvim/commit/1b9e04685097b93a4f8d01002adaaa5dcfb327af)) +* **tree:** pass parent_node to prepare_node function ([559d33d](https://github.com/MunifTanjim/nui.nvim/commit/559d33dcace8016603129e63e5cb605aaa059c10)) +* **tree:** rename options.ns to options.ns_id ([5db3901](https://github.com/MunifTanjim/nui.nvim/commit/5db390110bf9944b678c84cd7bcd2a28af712481)) +* **tree:** return end linenr from tree:get_node method ([8ae5e31](https://github.com/MunifTanjim/nui.nvim/commit/8ae5e3106a0fa17144a0a086ccfce1e73a73f19f)) +* **tree:** return linenr from tree:get_node method ([3b746d7](https://github.com/MunifTanjim/nui.nvim/commit/3b746d7b6f16818a970d1c4810261d18b958a956)) +* **tree:** support linenr for method tree:get_node ([7fee7c6](https://github.com/MunifTanjim/nui.nvim/commit/7fee7c6c176e83806a144f7e0a8ac6251920a8a7)) +* **tree:** support linenr_start for method :render ([afb9e5b](https://github.com/MunifTanjim/nui.nvim/commit/afb9e5b4512e17d879fb069e77de4141472d0fb9)) +* **tree:** support multiline node ([4926ee9](https://github.com/MunifTanjim/nui.nvim/commit/4926ee9ba8fac49ad23cd695f1f5c0952c52dd4a)) +* **tree:** support nil return for o.prepare_node ([5a79b1b](https://github.com/MunifTanjim/nui.nvim/commit/5a79b1b3b8231cfa33290d8d3c56d36b6496499e)) +* use api-autocmd if available ([3f05d74](https://github.com/MunifTanjim/nui.nvim/commit/3f05d742b273ed4b48db4e5ab99c09b8b05cd537)) +* use native keymap callback if supported ([ad2c05c](https://github.com/MunifTanjim/nui.nvim/commit/ad2c05c983dda8d423d952fce55eb3cf3966a1ea)) +* **utils:** add autocmd ([1f51d5a](https://github.com/MunifTanjim/nui.nvim/commit/1f51d5a6735153ea5e3f9a1f68a12c9f1bce5681)) +* **utils:** add buf_storage ([6300e3b](https://github.com/MunifTanjim/nui.nvim/commit/6300e3bdcc2fc38e361a48b76247c4524a0fb27a)) +* **utils:** backport autocmd to nvim < 0.7.x ([587a49f](https://github.com/MunifTanjim/nui.nvim/commit/587a49f90fb036a6d94904851dafdd53d1327fe0)) +* **utils:** move keymap to utils ([56c2230](https://github.com/MunifTanjim/nui.nvim/commit/56c223041ad342b2a8d28d2bb1dbd88dc0d7839a)) +* **utils:** update autocmd.event ([6f9153c](https://github.com/MunifTanjim/nui.nvim/commit/6f9153cc8462a3bb0a8a883694befa6554d31403)) +* **window:** add method window:destroy() ([c51856d](https://github.com/MunifTanjim/nui.nvim/commit/c51856da53df1327a406e7983d5fd748486fc339)) +* **window:** add method window:map(...) ([f69ee04](https://github.com/MunifTanjim/nui.nvim/commit/f69ee0498ecc57268d23a4b51516002404c3def1)) +* **window:** add method window:on(event_name, handler) ([f36b496](https://github.com/MunifTanjim/nui.nvim/commit/f36b496b52aec95dc9e122830b0d2ff64eeb21f5)) +* **window:** add method window:render() ([d1a047d](https://github.com/MunifTanjim/nui.nvim/commit/d1a047d22d0794943a08a2de598ac57c61fc4978)) +* **window:** add option highlight ([c4bbe61](https://github.com/MunifTanjim/nui.nvim/commit/c4bbe6139f012aca971ea26721dc4c1942b71286)) +* **window:** change default border to none ([8db2faa](https://github.com/MunifTanjim/nui.nvim/commit/8db2faa3dfa9b196fe2fd11311c534b37db2428a)) +* **window:** enhanced border support ([a17070c](https://github.com/MunifTanjim/nui.nvim/commit/a17070c6b55ba36db82edef0c3af28ac0929e649)) +* **window:** initial implementation ([19e4bb6](https://github.com/MunifTanjim/nui.nvim/commit/19e4bb669d2fdd168a6d0a3edebaca150f93678f)) +* **window:** rename 'destroy' to 'unmount' ([c409518](https://github.com/MunifTanjim/nui.nvim/commit/c409518e6640e50dc754b199cd37246f60a3fdbc)) +* **window:** rename 'render' to 'mount' ([66190d2](https://github.com/MunifTanjim/nui.nvim/commit/66190d237fc61db2135c0c19eb9091091d9090dc)) +* **window:** simplify option relative ([5584892](https://github.com/MunifTanjim/nui.nvim/commit/55848921914083d57e178448f3714f3ba4d9fc75)) + + +### Bug Fixes + +* **bar:** rename tabnr to tabid in context ([f220495](https://github.com/MunifTanjim/nui.nvim/commit/f2204952ba670372de507bdd446c6a14f821ac73)) +* **bar:** type for generator ([2a6533f](https://github.com/MunifTanjim/nui.nvim/commit/2a6533fb798efad7dd783311315bab8dc5eb381b)) +* **input:** escape multi-byte chars in default value ([698e758](https://github.com/MunifTanjim/nui.nvim/commit/698e75814cd7c56b0dd8af4936bcef2d13807f3c)) +* **input:** stopinsert on close ([971cca4](https://github.com/MunifTanjim/nui.nvim/commit/971cca41914aaefc9569b8a1904cf8c25cec5aa8)) +* **input:** try to keep stable cursor position on parent window ([6f803e8](https://github.com/MunifTanjim/nui.nvim/commit/6f803e88093573f73d4ee6c0dfe0575df3f97a9f)) +* **layout:** apply size for first child box in split layout ([dde3f89](https://github.com/MunifTanjim/nui.nvim/commit/dde3f89b74b726eacfa6bea82c895af265573fe4)) +* **layout:** float position calculation for child with complex border ([257da38](https://github.com/MunifTanjim/nui.nvim/commit/257da38029d3859ed111804f9d4e95b0fa993a31)) +* **layout:** focus on relative.win for split ':update' ([6e8f9a0](https://github.com/MunifTanjim/nui.nvim/commit/6e8f9a0f280fada3f8ce694a42f8376a31e9776e)) +* **layout:** o.relative for split layout ([bf5900f](https://github.com/MunifTanjim/nui.nvim/commit/bf5900f1b60bf6499755ac92315181a24a87a577)) +* **layout:** preserve split win_options 'winfixheight' and 'winfixwidth' ([ecd9def](https://github.com/MunifTanjim/nui.nvim/commit/ecd9def93891b9260b15b5fcef542eaabf4145c9)) +* **layout:** process float layout box change ([51721a4](https://github.com/MunifTanjim/nui.nvim/commit/51721a409794bf2e432e99acc21b635102fedcea)) +* **layout:** process split layout box change ([b12db53](https://github.com/MunifTanjim/nui.nvim/commit/b12db5321c194c10eb34e610fb76ce2c058853fc)) +* **layout:** typo in update_layout_config util ([d5d3d6c](https://github.com/MunifTanjim/nui.nvim/commit/d5d3d6ce542b3921f8a8da213ef7b0841f6a4adc)) +* luacheck lint warnings ([e7dd31c](https://github.com/MunifTanjim/nui.nvim/commit/e7dd31c4135389e460a686d48d10ed532ca5234e)) +* **menu:** error with fallback separator char ([76fc8ed](https://github.com/MunifTanjim/nui.nvim/commit/76fc8edf771adff74aba513bd0f62e984996ef88)) +* **popup:** add border.style default value ([a07b754](https://github.com/MunifTanjim/nui.nvim/commit/a07b754552008012f2d7d3602b7a233a29d92c66)) +* **popup:** border highlight for type:complex ([151d593](https://github.com/MunifTanjim/nui.nvim/commit/151d593b28911d61b10e1a8ba14f9e4c755141aa)) +* **popup:** border padding with style=shadow ([37e0511](https://github.com/MunifTanjim/nui.nvim/commit/37e0511f189cd19eabd0e71841f10c3a39bbb62d)) +* **popup:** check bufnr is valid before clearing namespace ([62facd3](https://github.com/MunifTanjim/nui.nvim/commit/62facd37e0dd8196212399a897374f689886f500)) +* **popup:** check if border bufnr is valid before clearing namespace ([b99e6cb](https://github.com/MunifTanjim/nui.nvim/commit/b99e6cb13dc51768abc1c4c8585045a0c0459ef1)) +* **popup:** do better mount/unmount handling ([7622fcf](https://github.com/MunifTanjim/nui.nvim/commit/7622fcf3dc4cffffc666d9f1f4e646168f640a2a)) +* **popup:** do buf_storage.cleanup after buffer wipeout ([644e595](https://github.com/MunifTanjim/nui.nvim/commit/644e595a3862ca45de71ff14fa465019ecfc17ad)) +* **popup:** do BufWinEnter autocmd after self.winid is set ([4c77e3a](https://github.com/MunifTanjim/nui.nvim/commit/4c77e3a064b7b0fdfb5f2729500a81b431ff86f8)) +* **popup:** do not reset win_options on update_layout call ([1f43b13](https://github.com/MunifTanjim/nui.nvim/commit/1f43b13d133eb4b4f53a4485379d9afa58808389)) +* **popup:** handle border padding without text ([1f9aebc](https://github.com/MunifTanjim/nui.nvim/commit/1f9aebcaca1f43c311ba16a2aad9170d597640a8)) +* **popup:** handle border:set_text properly ([e78c822](https://github.com/MunifTanjim/nui.nvim/commit/e78c822378c102978792465dbf93b8ffb27a4cc8)) +* **popup:** handle map as border.style ([26a0eea](https://github.com/MunifTanjim/nui.nvim/commit/26a0eeaca8890b74d53a91f84520abea94fcf8ee)) +* **popup:** handle various mix of border and padding ([49a155f](https://github.com/MunifTanjim/nui.nvim/commit/49a155f3bbacb90a8b2df320f5b127b568083cd6)) +* **popup:** highlight for simple border ([3ff3d26](https://github.com/MunifTanjim/nui.nvim/commit/3ff3d2628f3c14633792f702b3cfea377addcf47)) +* **popup:** ignore WinClosed from other popup ([a501202](https://github.com/MunifTanjim/nui.nvim/commit/a5012020a48f5740bf30bb3468ca532cb7627997)) +* **popup:** manual doautocmd BufWinEnter ([0807a9c](https://github.com/MunifTanjim/nui.nvim/commit/0807a9ca3274d5f89d02802aa00dac9fc2649864)) +* **popup:** position relative to buffer position ([f369333](https://github.com/MunifTanjim/nui.nvim/commit/f36933397a0689a96106d9aa74db320286f5ffda)) +* **popup:** properly apply winhighlight ([4396e44](https://github.com/MunifTanjim/nui.nvim/commit/4396e4427dc7ec80c58575096fdcad46d336a7ac)) +* **popup:** remove mutation in border:get() ([1179f2e](https://github.com/MunifTanjim/nui.nvim/commit/1179f2e3f5245ab585e1154478f45bab1cf41867)) +* **popup:** respect 'enter' option on subsequent mounts ([602e4d8](https://github.com/MunifTanjim/nui.nvim/commit/602e4d885fda5da9f015345fecb8c745ffedbf52)) +* **popup:** set noautocmd for opening border window ([4715f60](https://github.com/MunifTanjim/nui.nvim/commit/4715f6092443f0b8fb9a3bcb0cfd03202bb03477)) +* **popup:** set win_config.win explicitly if applicable ([d50d84a](https://github.com/MunifTanjim/nui.nvim/commit/d50d84a340a3088b8560bf74fa3d6f6ad0556134)) +* **popup:** store winid for parent window ([03131f8](https://github.com/MunifTanjim/nui.nvim/commit/03131f8aa1873d08009ca214727a2c699d73dfe6)) +* **popup:** take border size_delta into account when calculating position ([e67310b](https://github.com/MunifTanjim/nui.nvim/commit/e67310b23d21ebe8b12d9dbadb3dfa562dda5057)) +* **popup:** track mounted state ([c1db7f8](https://github.com/MunifTanjim/nui.nvim/commit/c1db7f8377dfce4f724f35eec74868a15443dccf)) +* **popup:** update position handling ([d87b561](https://github.com/MunifTanjim/nui.nvim/commit/d87b56193991e102516689f394101d1367bf8ef5)) +* **popup:** update state and buffer handling for hide/show ([02e9262](https://github.com/MunifTanjim/nui.nvim/commit/02e9262d2d1820c2ed573f3a09848affc6526704)) +* **popup:** use copy of border styles table item ([abd1a4a](https://github.com/MunifTanjim/nui.nvim/commit/abd1a4a23d0bbc0d0d4bc8faaa240330b44a4d5c)) +* **popup:** use popup winhighlight for border ([cf67636](https://github.com/MunifTanjim/nui.nvim/commit/cf676363181aae149271226531abca341e583997)) +* **split:** check bufnr is valid before clearing namespace ([e9889bb](https://github.com/MunifTanjim/nui.nvim/commit/e9889bbd9919544697d497537acacd9c67d0de99)) +* **split:** do buf_storage.cleanup after buffer wipeout ([5bf5d62](https://github.com/MunifTanjim/nui.nvim/commit/5bf5d62531b473b01cae65ea389d4f788fb2341f)) +* **split:** do not update position when unchanged ([6d86148](https://github.com/MunifTanjim/nui.nvim/commit/6d86148ad43554f51caa82e99d63a57be1654182)) +* **split:** set size after open window ([c45ad68](https://github.com/MunifTanjim/nui.nvim/commit/c45ad685cb156174761f912c7697fdb6fd5a3b50)) +* **split:** skip manual buf/win removal when pending quit ([cc76e6f](https://github.com/MunifTanjim/nui.nvim/commit/cc76e6ff13629b18d3dedfadd4f52e35ff085700)) +* **split:** tweak container size relative to editor ([120fe69](https://github.com/MunifTanjim/nui.nvim/commit/120fe69bc4d96a13f89c2450ceb27fcd921b77ae)) +* **split:** use self.winid for WinClosed ([747c20d](https://github.com/MunifTanjim/nui.nvim/commit/747c20d10a42f22a0125ee2e7397aee4c2422ffe)) +* support nui.text for internal alignment util ([915fabe](https://github.com/MunifTanjim/nui.nvim/commit/915fabe334639c384ed5c66005046b96d4e8d30a)) +* **text:** fix :highlight extmark.end_col ([3775746](https://github.com/MunifTanjim/nui.nvim/commit/3775746f8324db1ff5068eaf10f321db5e566611)) +* **tree:** fix calculation for method :render ([70f2dad](https://github.com/MunifTanjim/nui.nvim/commit/70f2dadb73b5aa15727ec8f7a620818997505be5)) +* **tree:** pass ns_id correctly to nui.line in :render method ([70fc6b6](https://github.com/MunifTanjim/nui.nvim/commit/70fc6b66c651538a78d393cf9fc830c81e919ca8)) +* **tree:** remove children recursively in method remove_node ([4939282](https://github.com/MunifTanjim/nui.nvim/commit/4939282919885e1c83aff68ecb35b3cadf6015a9)) +* **tree:** remove id from .nodes.root_ids on tree:remove_node ([792caa3](https://github.com/MunifTanjim/nui.nvim/commit/792caa3c1dc3efb22385b3b363c32567ba9cb059)) +* **tree:** set default buf_options.bufhidden=hide ([7c7bdf4](https://github.com/MunifTanjim/nui.nvim/commit/7c7bdf40219bc274a849fa241daa47872c809fd8)) +* **tree:** set default buf_options.undolevels=0 ([42552b3](https://github.com/MunifTanjim/nui.nvim/commit/42552b3797c3452c5c94e0c84a04fbda9591b9d1)) +* vim.schedule QuitPre event callback ([d147222](https://github.com/MunifTanjim/nui.nvim/commit/d147222a1300901656f3ebd5b95f91732785a329)) +* **window:** cleanup properly ([dbc8185](https://github.com/MunifTanjim/nui.nvim/commit/dbc81850faeeb70c303cdf3c50e0a4b6bbf154bd)) +* **window:** set zindex properly ([2d427f7](https://github.com/MunifTanjim/nui.nvim/commit/2d427f7f9bac670523de2906776117192f243d8c)) +* **window:** use 0-indexed position ([fbf96df](https://github.com/MunifTanjim/nui.nvim/commit/fbf96df95e437687206b9213924372c13c328b7a)) + + +### Continuous Integration + +* introduce automated release ([70560c4](https://github.com/MunifTanjim/nui.nvim/commit/70560c4e7b36ff974e7136d08aa4022e79a002f4)) diff --git a/bundle/nui.nvim/LICENSE b/bundle/nui.nvim/LICENSE new file mode 100644 index 000000000..6ddc2c56a --- /dev/null +++ b/bundle/nui.nvim/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Munif Tanjim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bundle/nui.nvim/README.md b/bundle/nui.nvim/README.md new file mode 100644 index 000000000..5d42aee9c --- /dev/null +++ b/bundle/nui.nvim/README.md @@ -0,0 +1,345 @@ +![GitHub Workflow Status: CI](https://img.shields.io/github/actions/workflow/status/MunifTanjim/nui.nvim/ci.yml?branch=main&label=CI&style=for-the-badge) +[![Coverage](https://img.shields.io/codecov/c/gh/MunifTanjim/nui.nvim/master?style=for-the-badge)](https://codecov.io/gh/MunifTanjim/nui.nvim) +[![Version](https://img.shields.io/luarocks/v/MunifTanjim/nui.nvim?color=%232c3e67&style=for-the-badge)](https://luarocks.org/modules/MunifTanjim/nui.nvim) +![License](https://img.shields.io/github/license/MunifTanjim/nui.nvim?color=%23000080&style=for-the-badge) + +# nui.nvim + +UI Component Library for Neovim. + +## Requirements + +- [Neovim 0.5.0](https://github.com/neovim/neovim/releases/tag/v0.5.0) + +## Installation + +Install the plugins with your preferred plugin manager. For example, with [`vim-plug`](https://github.com/junegunn/vim-plug): + +```vim +Plug 'MunifTanjim/nui.nvim' +``` + +## Blocks + +### [NuiText](lua/nui/text) + +Quickly add highlighted text on the buffer. + +**[Check Detailed Documentation for `nui.text`](lua/nui/text)** + +**[Check Wiki Page for `nui.text`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.text)** + +### [NuiLine](lua/nui/line) + +Quickly add line containing highlighted text chunks on the buffer. + +**[Check Detailed Documentation for `nui.line`](lua/nui/line)** + +**[Check Wiki Page for `nui.line`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.line)** + +### [NuiTree](lua/nui/tree) + +Quickly render tree-like structured content on the buffer. + +**[Check Detailed Documentation for `nui.tree`](lua/nui/tree)** + +**[Check Wiki Page for `nui.tree`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.tree)** + +## Components + +### [Layout](lua/nui/layout) + +![Layout GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/layout.gif) + +```lua +local Popup = require("nui.popup") +local Layout = require("nui.layout") + +local popup_one, popup_two = Popup({ + enter = true, + border = "single", +}), Popup({ + border = "double", +}) + +local layout = Layout( + { + position = "50%", + size = { + width = 80, + height = "60%", + }, + }, + Layout.Box({ + Layout.Box(popup_one, { size = "40%" }), + Layout.Box(popup_two, { size = "60%" }), + }, { dir = "row" }) +) + +local current_dir = "row" + +popup_one:map("n", "r", function() + if current_dir == "col" then + layout:update(Layout.Box({ + Layout.Box(popup_one, { size = "40%" }), + Layout.Box(popup_two, { size = "60%" }), + }, { dir = "row" })) + + current_dir = "row" + else + layout:update(Layout.Box({ + Layout.Box(popup_two, { size = "60%" }), + Layout.Box(popup_one, { size = "40%" }), + }, { dir = "col" })) + + current_dir = "col" + end +end, {}) + +layout:mount() +``` + +**[Check Detailed Documentation for `nui.layout`](lua/nui/layout)** + +**[Check Wiki Page for `nui.layout`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.layout)** + +### [Popup](lua/nui/popup) + +![Popup GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/popup.gif) + +```lua +local Popup = require("nui.popup") +local event = require("nui.utils.autocmd").event + +local popup = Popup({ + enter = true, + focusable = true, + border = { + style = "rounded", + }, + position = "50%", + size = { + width = "80%", + height = "60%", + }, +}) + +-- mount/open the component +popup:mount() + +-- unmount component when cursor leaves buffer +popup:on(event.BufLeave, function() + popup:unmount() +end) + +-- set content +vim.api.nvim_buf_set_lines(popup.bufnr, 0, 1, false, { "Hello World" }) +``` + +**[Check Detailed Documentation for `nui.popup`](lua/nui/popup)** + +**[Check Wiki Page for `nui.popup`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.popup)** + +### [Input](lua/nui/input) + +![Input GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/input.gif) + +```lua +local Input = require("nui.input") +local event = require("nui.utils.autocmd").event + +local input = Input({ + position = "50%", + size = { + width = 20, + }, + border = { + style = "single", + text = { + top = "[Howdy?]", + top_align = "center", + }, + }, + win_options = { + winhighlight = "Normal:Normal,FloatBorder:Normal", + }, +}, { + prompt = "> ", + default_value = "Hello", + on_close = function() + print("Input Closed!") + end, + on_submit = function(value) + print("Input Submitted: " .. value) + end, +}) + +-- mount/open the component +input:mount() + +-- unmount component when cursor leaves buffer +input:on(event.BufLeave, function() + input:unmount() +end) +``` + +**[Check Detailed Documentation for `nui.input`](lua/nui/input)** + +**[Check Wiki Page for `nui.input`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.input)** + +### [Menu](lua/nui/menu) + +![Menu GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/menu.gif) + +```lua +local Menu = require("nui.menu") +local event = require("nui.utils.autocmd").event + +local menu = Menu({ + position = "50%", + size = { + width = 25, + height = 5, + }, + border = { + style = "single", + text = { + top = "[Choose-an-Element]", + top_align = "center", + }, + }, + win_options = { + winhighlight = "Normal:Normal,FloatBorder:Normal", + }, +}, { + lines = { + Menu.item("Hydrogen (H)"), + Menu.item("Carbon (C)"), + Menu.item("Nitrogen (N)"), + Menu.separator("Noble-Gases", { + char = "-", + text_align = "right", + }), + Menu.item("Helium (He)"), + Menu.item("Neon (Ne)"), + Menu.item("Argon (Ar)"), + }, + max_width = 20, + keymap = { + focus_next = { "j", "", "" }, + focus_prev = { "k", "", "" }, + close = { "", "" }, + submit = { "", "" }, + }, + on_close = function() + print("Menu Closed!") + end, + on_submit = function(item) + print("Menu Submitted: ", item.text) + end, +}) + +-- mount the component +menu:mount() +``` + +**[Check Detailed Documentation for `nui.menu`](lua/nui/menu)** + +**[Check Wiki Page for `nui.menu`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.menu)** + +### [Split](lua/nui/split) + +![Split GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/split.gif) + +```lua +local Split = require("nui.split") +local event = require("nui.utils.autocmd").event + +local split = Split({ + relative = "editor", + position = "bottom", + size = "20%", +}) + +-- mount/open the component +split:mount() + +-- unmount component when cursor leaves buffer +split:on(event.BufLeave, function() + split:unmount() +end) +``` + +**[Check Detailed Documentation for `nui.split`](lua/nui/split)** + +**[Check Wiki Page for `nui.split`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.split)** + +## Extendibility + +Each of the [blocks](#blocks) and [components](#components) can be extended to add new +methods or change their behaviors. + +```lua +local Timer = Popup:extend("Timer") + +function Timer:init(popup_options) + local options = vim.tbl_deep_extend("force", popup_options or {}, { + border = "double", + focusable = false, + position = { row = 0, col = "100%" }, + size = { width = 10, height = 1 }, + win_options = { + winhighlight = "Normal:Normal,FloatBorder:SpecialChar", + }, + }) + + Timer.super.init(self, options) +end + +function Timer:countdown(time, step, format) + local function draw_content(text) + local gap_width = 10 - vim.api.nvim_strwidth(text) + vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, { + string.format( + "%s%s%s", + string.rep(" ", math.floor(gap_width / 2)), + text, + string.rep(" ", math.ceil(gap_width / 2)) + ), + }) + end + + self:mount() + + local remaining_time = time + + draw_content(format(remaining_time)) + + vim.fn.timer_start(step, function() + remaining_time = remaining_time - step + + draw_content(format(remaining_time)) + + if remaining_time <= 0 then + self:unmount() + end + end, { ["repeat"] = math.ceil(remaining_time / step) }) +end + +local timer = Timer() + +timer:countdown(10000, 1000, function(time) + return tostring(time / 1000) .. "s" +end) +``` + +#### `nui.object` + +A small object library is bundled with `nui.nvim`. It is, more or less, a clone of the +[`kikito/middleclass`](https://github.com/kikito/middleclass) library. + +[Check Wiki Page for `nui.object`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.object) + +## License + +Licensed under the MIT License. Check the [LICENSE](./LICENSE) file for details. diff --git a/bundle/nui.nvim/lua/nui/input/README.md b/bundle/nui.nvim/lua/nui/input/README.md new file mode 100644 index 000000000..4963f6256 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/input/README.md @@ -0,0 +1,102 @@ +# Input + +Input is an abstraction layer on top of Popup. + +It uses prompt buffer (check `:h prompt-buffer`) for its popup window. + +```lua +local Input = require("nui.input") +local event = require("nui.utils.autocmd").event + +local popup_options = { + relative = "cursor", + position = { + row = 1, + col = 0, + }, + size = 20, + border = { + style = "rounded", + text = { + top = "[Input]", + top_align = "left", + }, + }, + win_options = { + winhighlight = "Normal:Normal", + }, +} + +local input = Input(popup_options, { + prompt = "> ", + default_value = "42", + on_close = function() + print("Input closed!") + end, + on_submit = function(value) + print("Value submitted: ", value) + end, + on_change = function(value) + print("Value changed: ", value) + end, +}) +``` + +If you provide the `on_change` function, it'll be run everytime value changes. + +Pressing `` runs the `on_submit` callback function and closes the window. +Pressing `` runs the `on_close` callback function and closes the window. + +Of course, you can override the default keymaps and add more. For example: + +```lua +-- unmount input by pressing `` in normal mode +input:map("n", "", function() + input:unmount() +end, { noremap = true }) +``` + +You can manipulate the assocciated buffer and window using the +`split.bufnr` and `split.winid` properties. + +**NOTE**: the first argument accepts options for `nui.popup` component. + +## Options + +### `prompt` + +**Type:** `string` or `NuiText` + +Prefix in the input. + +### `default_value` + +**Type:** `string` + +Default value placed in the input on mount + +### `on_close` + +Callback function, called when input is closed. + +### `on_submit` + +Callback function, called when input value is submitted. + +### `on_change` + +Callback function, called when input value is changed. + +### `disable_cursor_position_patch` + +By default, `nui.input` will try to make sure the cursor on parent window is not +moved after input is submitted/closed. If you want to disable this behavior +for some reason, you can set `disable_cursor_position_patch` to `true`. + +## Methods + +Methods from `nui.popup` are also available for `nui.input`. + +## Wiki Page + +You can find additional documentation/examples/guides/tips-n-tricks in [nui.input wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.input). diff --git a/bundle/nui.nvim/lua/nui/input/init.lua b/bundle/nui.nvim/lua/nui/input/init.lua new file mode 100644 index 000000000..6a6028a1a --- /dev/null +++ b/bundle/nui.nvim/lua/nui/input/init.lua @@ -0,0 +1,143 @@ +local Popup = require("nui.popup") +local Text = require("nui.text") +local defaults = require("nui.utils").defaults +local is_type = require("nui.utils").is_type +local event = require("nui.utils.autocmd").event + +-- exiting insert mode places cursor one character backward, +-- so patch the cursor position to one character forward +-- when unmounting input. +---@param target_cursor number[] +---@param force? boolean +local function patch_cursor_position(target_cursor, force) + local cursor = vim.api.nvim_win_get_cursor(0) + + if target_cursor[2] == cursor[2] and force then + -- didn't exit insert mode yet, but it's gonna + vim.api.nvim_win_set_cursor(0, { cursor[1], cursor[2] + 1 }) + elseif target_cursor[2] - 1 == cursor[2] then + -- already exited insert mode + vim.api.nvim_win_set_cursor(0, { cursor[1], cursor[2] + 1 }) + end +end + +---@alias nui_input_internal nui_popup_internal|{ default_value: string, prompt: NuiText } + +---@class NuiInput: NuiPopup +---@field private _ nui_input_internal +local Input = Popup:extend("NuiInput") + +---@param popup_options table +---@param options table +function Input:init(popup_options, options) + popup_options.enter = true + + popup_options.buf_options = defaults(popup_options.buf_options, {}) + popup_options.buf_options.buftype = "prompt" + + if not is_type("table", popup_options.size) then + popup_options.size = { + width = popup_options.size, + } + end + + popup_options.size.height = 1 + + Input.super.init(self, popup_options) + + self._.default_value = defaults(options.default_value, "") + self._.prompt = Text(defaults(options.prompt, "")) + self._.disable_cursor_position_patch = defaults(options.disable_cursor_position_patch, false) + + local props = {} + + self.input_props = props + + props.on_submit = function(value) + local target_cursor = vim.api.nvim_win_get_cursor(self._.position.win) + + local prompt_normal_mode = vim.fn.mode() == "n" + + self:unmount() + + vim.schedule(function() + if prompt_normal_mode then + -- NOTE: on prompt-buffer normal mode causes neovim to enter insert mode. + -- ref: https://github.com/neovim/neovim/blob/d8f5f4d09078/src/nvim/normal.c#L5327-L5333 + vim.api.nvim_command("stopinsert") + end + + if not self._.disable_cursor_position_patch then + patch_cursor_position(target_cursor, prompt_normal_mode) + end + + if options.on_submit then + options.on_submit(value) + end + end) + end + + props.on_close = function() + local target_cursor = vim.api.nvim_win_get_cursor(self._.position.win) + + self:unmount() + + vim.schedule(function() + if vim.fn.mode() == "i" then + vim.api.nvim_command("stopinsert") + end + + if not self._.disable_cursor_position_patch then + patch_cursor_position(target_cursor) + end + + if options.on_close then + options.on_close() + end + end) + end + + if options.on_change then + props.on_change = function() + local value_with_prompt = vim.api.nvim_buf_get_lines(self.bufnr, 0, 1, false)[1] + local value = string.sub(value_with_prompt, self._.prompt:length() + 1) + options.on_change(value) + end + end +end + +function Input:mount() + local props = self.input_props + + Input.super.mount(self) + + if props.on_change then + vim.api.nvim_buf_attach(self.bufnr, false, { + on_lines = props.on_change, + }) + end + + if #self._.default_value then + self:on(event.InsertEnter, function() + vim.api.nvim_feedkeys(self._.default_value, "t", true) + end, { once = true }) + end + + vim.fn.prompt_setprompt(self.bufnr, self._.prompt:content()) + if self._.prompt:length() > 0 then + vim.schedule(function() + self._.prompt:highlight(self.bufnr, self.ns_id, 1, 0) + end) + end + + vim.fn.prompt_setcallback(self.bufnr, props.on_submit) + vim.fn.prompt_setinterrupt(self.bufnr, props.on_close) + + vim.api.nvim_command("startinsert!") +end + +---@alias NuiInput.constructor fun(popup_options: table, options: table): NuiInput +---@type NuiInput|NuiInput.constructor +local NuiInput = Input + +return NuiInput diff --git a/bundle/nui.nvim/lua/nui/layout/README.md b/bundle/nui.nvim/lua/nui/layout/README.md new file mode 100644 index 000000000..b53e72595 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/layout/README.md @@ -0,0 +1,240 @@ +# Layout + +Layout is a helper component for creating complex layout by automatically +handling the calculation for position and size of other components. + +**Example** + +```lua +local Layout = require("nui.layout") +local Popup = require("nui.popup") + +local top_popup = Popup({ border = "single" }) +local bottom_popup = Popup({ border = "double" }) + +local layout = Layout( + { + position = "50%", + size = { + width = 80, + height = 40, + }, + }, + Layout.Box({ + Layout.Box(top_popup, { size = "40%" }), + Layout.Box(bottom_popup, { size = "60%" }), + }, { dir = "col" }) +) + +layout:mount() +``` + +_Signature:_ `Layout(options, box)` or `Layout(component, box)` + +`component` can be `Popup` or `Split`. + +## Options + +### `relative` + +**Type:** `string` or `table` + +This option affects how `position` and `size` are calculated. + +**Examples** + +Relative to cursor on current window: + +```lua +relative = "cursor", +``` + +Relative to the current editor screen: + +```lua +relative = "editor", +``` + +Relative to the current window (_default_): + +```lua +relative = "win", +``` + +Relative to the window with specific id: + +```lua +relative = { + type = "win", + winid = 5, +}, +``` + +Relative to the buffer position: + +```lua +relative = { + type = "buf", + -- zero-indexed + position = { + row = 5, + col = 5, + }, +}, +``` + +--- + +### `position` + +**Type:** `number` or `percentage string` or `table` + +Position is calculated from the top-left corner. + +If `position` is `number` or `percentage string`, it applies to both `row` and `col`. +Or you can pass a table to set them separately. + +For `percentage string`, position is calculated according to the option `relative`. +If `relative` is set to `"buf"` or `"cursor"`, `percentage string` is not allowed. + +**Examples** + +```lua +position = 50, +``` + +```lua +position = "50%", +``` + +```lua +position = { + row = 30, + col = 20, +}, +``` + +```lua +position = { + row = "20%", + col = "50%", +}, +``` + +--- + +### `size` + +**Type:** `number` or `percentage string` or `table` + +Determines the size of the layout. + +If `size` is `number` or `percentage string`, it applies to both `width` and `height`. +You can also pass a table to set them separately. + +For `percentage string`, `size` is calculated according to the option `relative`. +If `relative` is set to `"buf"` or `"cursor"`, window size is considered. + +**Examples** + +```lua +size = 50, +``` + +```lua +size = "50%", +``` + +```lua +size = { + width = 80, + height = 40, +}, +``` + +```lua +size = { + width = "80%", + height = "60%", +}, +``` + +## Layout.Box + +_Signature:_ `Layout.Box(box, options)` + +**Parameters** + +| Name | Type | Description | +| --------- | ------------------------------ | ----------------------------------------- | +| `box` | `Layout.Box[]` / nui component | list of `Layout.Box` or any nui component | +| `options` | `table` | box options | + +`options` is a `table` having the following keys: + +| Key | Type | Description | +| ------ | ----------------------------- | ------------------------------------------------------ | +| `dir` | `"col"` / `"row"` (_default_) | arrangement direction, only if `box` is `Layout.Box[]` | +| `grow` | `number` | growth factor to fill up the box free space | +| `size` | `number` / `string` / `table` | optional if `grow` is present | + +## Methods + +### `layout:mount` + +_Signature:_ `layout:mount()` + +Mounts the layout with all the components. + +**Examples** + +```lua +layout:mount() +``` + +### `layout:unmount` + +_Signature:_ `layout:unmount()` + +Unmounts the layout with all the components. + +**Examples** + +```lua +layout:unmount() +``` + +### `layout:hide` + +_Signature:_ `layout:hide()` + +Hides the layout with all the components. Preserves the buffer (related content, autocmds and keymaps). + +### `layout:show` + +_Signature:_ `layout:show()` + +Shows the hidden layout with all the components. + +### `layout:update` + +_Signature:_ `layout:update(config, box?)` or `layout:update(box?)` + +**Parameters** + +`config` is a `table` having the following keys: + +| Key | Type | +| ---------- | ------------------ | +| `relative` | `string` / `table` | +| `position` | `string` / `table` | +| `size` | `string` / `table` | + +`box` is a `table` returned by `Layout.Box`. + +They are the same options used for layout initialization. + +## Wiki Page + +You can find additional documentation/examples/guides/tips-n-tricks in +[nui.layout wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.layout). diff --git a/bundle/nui.nvim/lua/nui/layout/float.lua b/bundle/nui.nvim/lua/nui/layout/float.lua new file mode 100644 index 000000000..24741f898 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/layout/float.lua @@ -0,0 +1,225 @@ +local utils = require("nui.utils") +local layout_utils = require("nui.layout.utils") + +local u = { + is_type = utils.is_type, + calculate_window_size = layout_utils.calculate_window_size, +} + +local mod = {} + +local function get_child_position(box, child, current_position, canvas_position) + local position = box.dir == "row" and { + row = canvas_position.row, + col = current_position.col, + } or { + col = canvas_position.col, + row = current_position.row, + } + + if child.component then + local border = child.component.border + if border and border._.type == "complex" then + position.col = position.col + math.floor(border._.size_delta.width / 2 + 0.5) + position.row = position.row + math.floor(border._.size_delta.height / 2 + 0.5) + end + end + + return position +end + +---@param parent table Layout.Box +---@param child table Layout.Box +---@param container_size table +---@param growable_dimension_per_factor? number +local function get_child_size(parent, child, container_size, growable_dimension_per_factor) + local child_size = { + width = child.size.width, + height = child.size.height, + } + + if child.grow and growable_dimension_per_factor then + if parent.dir == "col" then + child_size.height = math.floor(growable_dimension_per_factor * child.grow) + else + child_size.width = math.floor(growable_dimension_per_factor * child.grow) + end + end + + local outer_size = u.calculate_window_size(child_size, container_size) + + local inner_size = { + width = outer_size.width, + height = outer_size.height, + } + + if child.component then + if child.component.border then + inner_size.width = inner_size.width - child.component.border._.size_delta.width + inner_size.height = inner_size.height - child.component.border._.size_delta.height + end + end + + return outer_size, inner_size +end + +function mod.process(box, meta) + if box.mount or box.component or not box.box then + return error("invalid paramter: box") + end + + local container_size = meta.container_size + if not u.is_type("number", container_size.width) or not u.is_type("number", container_size.height) then + return error("invalid value: box.size") + end + + local current_position = { + col = 0, + row = 0, + } + + local growable_child_factor = 0 + + for _, child in ipairs(box.box) do + if meta.process_growable_child or not child.grow then + local position = get_child_position(box, child, current_position, meta.position) + local outer_size, inner_size = get_child_size(box, child, container_size, meta.growable_dimension_per_factor) + + if child.component then + child.component:set_layout({ + size = inner_size, + relative = { + type = "win", + winid = meta.winid, + }, + position = position, + }) + else + mod.process(child, { + winid = meta.winid, + container_size = outer_size, + position = position, + }) + end + + current_position.col = current_position.col + outer_size.width + current_position.row = current_position.row + outer_size.height + end + + if child.grow then + growable_child_factor = growable_child_factor + child.grow + end + end + + if meta.process_growable_child or growable_child_factor == 0 then + return + end + + local growable_width = container_size.width - current_position.col + local growable_height = container_size.height - current_position.row + local growable_dimension = box.dir == "col" and growable_height or growable_width + local growable_dimension_per_factor = growable_dimension / growable_child_factor + + mod.process(box, { + winid = meta.winid, + container_size = meta.container_size, + position = meta.position, + process_growable_child = true, + growable_dimension_per_factor = growable_dimension_per_factor, + }) +end + +function mod.mount_box(box) + for _, child in ipairs(box.box) do + if child.component then + child.component:mount() + else + mod.mount_box(child) + end + end +end + +---@param box table Layout.Box +function mod.show_box(box) + for _, child in ipairs(box.box) do + if child.component then + child.component:show() + else + mod.show_box(child) + end + end +end + +function mod.unmount_box(box) + for _, child in ipairs(box.box) do + if child.component then + child.component:unmount() + else + mod.unmount_box(child) + end + end +end + +---@param box table Layout.Box +function mod.hide_box(box) + for _, child in ipairs(box.box) do + if child.component then + child.component:hide() + else + mod.hide_box(child) + end + end +end + +---@param box table Layout.Box +---@return table +local function collect_box_components(box, components) + if not components then + components = {} + end + + for _, child in ipairs(box.box) do + if child.component then + components[child.component._.id] = child.component + else + collect_box_components(child, components) + end + end + + return components +end + +---@param curr_box table Layout.Box +---@param prev_box table Layout.Box +function mod.process_box_change(curr_box, prev_box) + if curr_box == prev_box then + return + end + + local curr_components = collect_box_components(curr_box) + local prev_components = collect_box_components(prev_box) + + for id, component in pairs(curr_components) do + if not prev_components[id] then + if not component.winid then + if component._.mounted then + component:show() + else + component:mount() + end + end + end + end + + for id, component in pairs(prev_components) do + if not curr_components[id] then + if component._.mounted then + if component.winid then + component:hide() + end + end + end + end +end + +return mod diff --git a/bundle/nui.nvim/lua/nui/layout/init.lua b/bundle/nui.nvim/lua/nui/layout/init.lua new file mode 100644 index 000000000..051071bcd --- /dev/null +++ b/bundle/nui.nvim/lua/nui/layout/init.lua @@ -0,0 +1,501 @@ +local Object = require("nui.object") +local Popup = require("nui.popup") +local Split = require("nui.split") +local utils = require("nui.utils") +local layout_utils = require("nui.layout.utils") +local float_layout = require("nui.layout.float") +local split_layout = require("nui.layout.split") +local split_utils = require("nui.split.utils") +local autocmd = require("nui.utils.autocmd") + +local _ = utils._ + +local defaults = utils.defaults +local is_type = utils.is_type +local u = { + get_next_id = _.get_next_id, + position = layout_utils.position, + size = layout_utils.size, + split = split_utils, + update_layout_config = layout_utils.update_layout_config, +} + +-- GitHub Issue: https://github.com/neovim/neovim/issues/18925 +local function apply_workaround_for_float_relative_position_issue_18925(layout) + local current_winid = vim.api.nvim_get_current_win() + + vim.api.nvim_set_current_win(layout.winid) + vim.api.nvim_command("redraw!") + vim.api.nvim_set_current_win(current_winid) +end + +local function merge_default_options(options) + options.relative = defaults(options.relative, "win") + + return options +end + +local function normalize_options(options) + options = _.normalize_layout_options(options) + + return options +end + +local function is_box(object) + return object and (object.box or object.component) +end + +local function is_component(object) + return object and object.mount +end + +local function is_component_mounted(component) + return is_type("number", component.winid) +end + +local function get_layout_config_relative_to_component(component) + return { + relative = { type = "win", winid = component.winid }, + position = { row = 0, col = 0 }, + size = { width = "100%", height = "100%" }, + } +end + +---@param layout NuiLayout +---@param box table Layout.Box +local function wire_up_layout_components(layout, box) + for _, child in ipairs(box.box) do + if child.component then + autocmd.create({ "BufWipeout", "QuitPre" }, { + group = layout._.augroup.unmount, + buffer = child.component.bufnr, + callback = vim.schedule_wrap(function() + layout:unmount() + end), + }, child.component.bufnr) + + autocmd.create("BufWinEnter", { + group = layout._.augroup.unmount, + buffer = child.component.bufnr, + callback = function() + local winid = child.component.winid + if layout._.type == "float" and not winid then + --[[ + `BufWinEnter` does not contain window id and + it is fired before `nvim_open_win` returns + the window id. + --]] + winid = vim.fn.bufwinid(child.component.bufnr) + end + + autocmd.create("WinClosed", { + group = layout._.augroup.hide, + pattern = tostring(winid), + callback = function() + layout:hide() + end, + }, child.component.bufnr) + end, + }, child.component.bufnr) + else + wire_up_layout_components(layout, child) + end + end +end + +---@class NuiLayout +local Layout = Object("NuiLayout") + +---@return '"float"'|'"split"' layout_type +local function get_layout_type(box) + for _, child in ipairs(box.box) do + if child.component and child.type then + return child.type + end + + local type = get_layout_type(child) + if type then + return type + end + end + + error("unexpected empty box") +end + +function Layout:init(options, box) + local id = u.get_next_id() + + box = Layout.Box(box) + + local type = get_layout_type(box) + + self._ = { + id = id, + type = type, + box = box, + loading = false, + mounted = false, + augroup = { + hide = string.format("%s_hide", id), + unmount = string.format("%s_unmount", id), + }, + } + + if type == "float" then + local container + if is_component(options) then + container = options + options = get_layout_config_relative_to_component(container) + else + options = merge_default_options(options) + options = normalize_options(options) + end + + self._[type] = { + container = container, + layout = {}, + win_enter = false, + win_config = { + focusable = false, + style = "minimal", + zindex = 49, + }, + win_options = { + winblend = 100, + }, + } + + if not is_component(container) or is_component_mounted(container) then + self:update(options) + end + end + + if type == "split" then + options = u.split.merge_default_options(options) + options = u.split.normalize_options(options) + + self._[type] = { + layout = {}, + position = options.position, + size = {}, + win_config = { + pending_changes = {}, + }, + } + + self:update(options) + end +end + +function Layout:_process_layout() + local type = self._.type + + if type == "float" then + local info = self._.float + + apply_workaround_for_float_relative_position_issue_18925(self) + + float_layout.process(self._.box, { + winid = self.winid, + container_size = info.size, + position = { + row = 0, + col = 0, + }, + }) + + return + end + + if type == "split" then + local info = self._.split + + split_layout.process(self._.box, { + position = info.position, + relative = info.relative, + container_size = info.size, + container_fallback_size = info.container_info.size, + }) + end +end + +function Layout:_open_window() + if self._.type == "float" then + local info = self._.float + + self.winid = vim.api.nvim_open_win(self.bufnr, info.win_enter, info.win_config) + assert(self.winid, "failed to create popup window") + + _.set_win_options(self.winid, info.win_options) + end +end + +function Layout:_close_window() + if not self.winid then + return + end + + if vim.api.nvim_win_is_valid(self.winid) then + vim.api.nvim_win_close(self.winid, true) + end + + self.winid = nil +end + +function Layout:mount() + if self._.loading or self._.mounted then + return + end + + self._.loading = true + + local type = self._.type + + if type == "float" then + local info = self._.float + + local container = info.container + if is_component(container) and not is_component_mounted(container) then + container:mount() + self:update(get_layout_config_relative_to_component(container)) + end + + if not self.bufnr then + self.bufnr = vim.api.nvim_create_buf(false, true) + assert(self.bufnr, "failed to create buffer") + end + + self:_open_window() + end + + self:_process_layout() + + if type == "float" then + float_layout.mount_box(self._.box) + end + + if type == "split" then + split_layout.mount_box(self._.box) + end + + self._.loading = false + self._.mounted = true +end + +function Layout:unmount() + if self._.loading or not self._.mounted then + return + end + + pcall(autocmd.delete_group, self._.augroup.hide) + pcall(autocmd.delete_group, self._.augroup.unmount) + + self._.loading = true + + local type = self._.type + + if type == "float" then + float_layout.unmount_box(self._.box) + + if self.bufnr then + if vim.api.nvim_buf_is_valid(self.bufnr) then + vim.api.nvim_buf_delete(self.bufnr, { force = true }) + end + self.bufnr = nil + end + + self:_close_window() + end + + if type == "split" then + split_layout.unmount_box(self._.box) + end + + self._.loading = false + self._.mounted = false +end + +function Layout:hide() + if self._.loading or not self._.mounted then + return + end + + self._.loading = true + + pcall(autocmd.delete_group, self._.augroup.hide) + + local type = self._.type + + if type == "float" then + float_layout.hide_box(self._.box) + + self:_close_window() + end + + if type == "split" then + split_layout.hide_box(self._.box) + end + + self._.loading = false +end + +function Layout:show() + if self._.loading or not self._.mounted then + return + end + + self._.loading = true + + autocmd.create_group(self._.augroup.hide, { clear = true }) + + local type = self._.type + + if type == "float" then + self:_open_window() + end + + self:_process_layout() + + if type == "float" then + float_layout.show_box(self._.box) + end + + if type == "split" then + split_layout.show_box(self._.box) + end + + self._.loading = false +end + +function Layout:update(config, box) + config = config or {} + + if not box and is_box(config) or is_box(config[1]) then + box = config + config = {} + end + + autocmd.create_group(self._.augroup.hide, { clear = true }) + autocmd.create_group(self._.augroup.unmount, { clear = true }) + + local prev_box = self._.box + + if box then + self._.box = Layout.Box(box) + self._.type = get_layout_type(self._.box) + end + + if self._.type == "float" then + local info = self._.float + + u.update_layout_config(info, config) + + if self.winid then + vim.api.nvim_win_set_config(self.winid, info.win_config) + + self:_process_layout() + + float_layout.process_box_change(self._.box, prev_box) + end + + wire_up_layout_components(self, self._.box) + end + + if self._.type == "split" then + local info = self._.split + + local relative_winid = info.relative and info.relative.win + + local prev_winid = vim.api.nvim_get_current_win() + if relative_winid then + vim.api.nvim_set_current_win(relative_winid) + end + + local curr_box = self._.box + if prev_box ~= curr_box then + self._.box = prev_box + self:hide() + self._.box = curr_box + end + + u.split.update_layout_config(info, config) + + if prev_box == curr_box then + self:_process_layout() + else + self:show() + end + + if vim.api.nvim_win_is_valid(prev_winid) then + vim.api.nvim_set_current_win(prev_winid) + end + + wire_up_layout_components(self, self._.box) + end +end + +function Layout.Box(box, options) + options = options or {} + + if is_box(box) then + return box + end + + if box.mount then + local type + if box:is_instance_of(Popup) then + type = "float" + elseif box:is_instance_of(Split) then + type = "split" + end + + if not type then + error("unsupported component") + end + + return { + type = type, + component = box, + grow = options.grow, + size = options.size, + } + end + + local dir = defaults(options.dir, "row") + + -- normalize children size + for _, child in ipairs(box) do + if not child.grow and not child.size then + error("missing child.size") + end + + if dir == "row" then + if not is_type("table", child.size) then + child.size = { width = child.size } + end + if not child.size.height then + child.size.height = "100%" + end + elseif dir == "col" then + if not is_type("table", child.size) then + child.size = { height = child.size } + end + if not child.size.width then + child.size.width = "100%" + end + end + end + + return { + box = box, + dir = dir, + grow = options.grow, + size = options.size, + } +end + +---@alias NuiLayout.constructor fun(options: table, box: table): NuiLayout +---@type NuiLayout|NuiLayout.constructor +local NuiLayout = Layout + +return NuiLayout diff --git a/bundle/nui.nvim/lua/nui/layout/split.lua b/bundle/nui.nvim/lua/nui/layout/split.lua new file mode 100644 index 000000000..b7c0a7b8c --- /dev/null +++ b/bundle/nui.nvim/lua/nui/layout/split.lua @@ -0,0 +1,260 @@ +local utils = require("nui.utils") +local split_utils = require("nui.split.utils") + +local u = { + is_type = utils.is_type, + split = split_utils, + set_win_options = utils._.set_win_options, +} + +local mod = {} + +---@param box_dir '"row"'|'"col"' +---@return nui_split_internal_position position +local function get_child_position(box_dir) + if box_dir == "row" then + return "right" + elseif box_dir == "col" then + return "bottom" + end +end + +---@param position nui_split_internal_position +---@param child { size: number|string|nui_layout_option_size, grow?: boolean } +---@param container_size { width?: number, height?: number } +---@param growable_dimension_per_factor? number +local function get_child_size(position, child, container_size, growable_dimension_per_factor) + local child_size + if not u.is_type("table", child.size) then + child_size = child.size --[[@as number|string]] + elseif position == "left" or position == "right" then + child_size = child.size.width + else + child_size = child.size.height + end + + if child.grow and growable_dimension_per_factor then + child_size = math.floor(growable_dimension_per_factor * child.grow) + end + + return u.split.calculate_window_size(position, child_size, container_size) +end + +local function get_container_size(meta) + local size = meta.container_size + size.width = size.width or meta.container_fallback_size.width + size.height = size.height or meta.container_fallback_size.height + return size +end + +function mod.process(box, meta) + if box.mount or box.component or not box.box then + return error("invalid paramter: box") + end + + local container_size = get_container_size(meta) + + if not u.is_type("number", container_size.width) and not u.is_type("number", container_size.height) then + return error("invalid value: box.size") + end + + local consumed_size = { + width = 0, + height = 0, + } + + local growable_child_factor = 0 + + for i, child in ipairs(box.box) do + if meta.process_growable_child or not child.grow then + local position = get_child_position(box.dir) + local relative = { type = "win" } + local size = get_child_size(position, child, container_size, meta.growable_dimension_per_factor) + + consumed_size.width = consumed_size.width + (size.width or 0) + consumed_size.height = consumed_size.height + (size.height or 0) + + if i == 1 then + position = meta.position + if meta.relative then + relative = meta.relative + end + if position == "left" or position == "right" then + size.width = container_size.width + else + size.height = container_size.height + end + end + + if child.component then + child.component:update_layout({ + position = position, + relative = relative, + size = size, + }) + if i == 1 and child.component.winid then + if position == "left" or position == "right" then + vim.api.nvim_win_set_height(child.component.winid, size.height) + else + vim.api.nvim_win_set_width(child.component.winid, size.width) + end + end + else + mod.process(child, { + container_size = size, + container_fallback_size = container_size, + position = position, + }) + end + end + + if child.grow then + growable_child_factor = growable_child_factor + child.grow + end + end + + if meta.process_growable_child or growable_child_factor == 0 then + return + end + + local growable_width = container_size.width - consumed_size.width + local growable_height = container_size.height - consumed_size.height + local growable_dimension = box.dir == "col" and growable_height or growable_width + local growable_dimension_per_factor = growable_dimension / growable_child_factor + + mod.process(box, { + container_size = meta.container_size, + container_fallback_size = meta.container_fallback_size, + position = meta.position, + process_growable_child = true, + growable_dimension_per_factor = growable_dimension_per_factor, + }) +end + +---@param box table Layout.Box +local function get_first_component(box) + if not box.box[1] then + return + end + + if box.box[1].component then + return box.box[1].component + end + + return get_first_component(box.box[1]) +end + +---@param box table Layout.Box +local function unset_win_options_fixsize(box) + for _, child in ipairs(box.box) do + if child.component then + local winfix = child.component._._layout_orig_winfixsize + if winfix then + child.component._.win_options.winfixwidth = winfix.winfixwidth + child.component._.win_options.winfixheight = winfix.winfixheight + child.component._._layout_orig_winfixsize = nil + end + u.set_win_options(child.component.winid, { + winfixwidth = child.component._.win_options.winfixwidth, + winfixheight = child.component._.win_options.winfixheight, + }) + else + unset_win_options_fixsize(child) + end + end +end + +---@param box table Layout.Box +---@param action '"mount"'|'"show"' +---@param meta? { initial_pass?: boolean } +local function do_action(box, action, meta) + meta = meta or { root = true } + + for i, child in ipairs(box.box) do + if not meta.initial_pass or i == 1 then + if child.component then + child.component._._layout_orig_winfixsize = { + winfixwidth = child.component._.win_options.winfixwidth, + winfixheight = child.component._.win_options.winfixheight, + } + + child.component._.win_options.winfixwidth = i ~= 1 + child.component._.win_options.winfixheight = i == 1 + if box.dir == "col" then + child.component._.win_options.winfixwidth = not child.component._.win_options.winfixwidth + child.component._.win_options.winfixheight = not child.component._.win_options.winfixheight + end + + if child.component and not child.component.winid then + child.component._.relative.win = vim.api.nvim_get_current_win() + child.component._.win_config.win = child.component._.relative.win + end + + child.component[action](child.component) + + if action == "show" and not child.component._.mounted then + child.component:mount() + end + else + do_action(child, action, { + initial_pass = true, + }) + end + end + end + + if not meta.initial_pass then + for _, child in ipairs(box.box) do + if child.box then + local first_component = get_first_component(child) + if first_component and first_component.winid then + vim.api.nvim_set_current_win(first_component.winid) + end + + do_action(child, action, { + initial_pass = false, + }) + end + end + end + + if meta.root then + unset_win_options_fixsize(box) + end +end + +---@param box table Layout.Box +---@param meta? { initial_pass?: boolean } +function mod.mount_box(box, meta) + do_action(box, "mount", meta) +end + +---@param box table Layout.Box +---@param meta? { initial_pass?: boolean } +function mod.show_box(box, meta) + do_action(box, "show", meta) +end + +---@param box table Layout.Box +function mod.unmount_box(box) + for _, child in ipairs(box.box) do + if child.component then + child.component:unmount() + else + mod.unmount_box(child) + end + end +end + +---@param box table Layout.Box +function mod.hide_box(box) + for _, child in ipairs(box.box) do + if child.component then + child.component:hide() + else + mod.hide_box(child) + end + end +end + +return mod diff --git a/bundle/nui.nvim/lua/nui/layout/utils.lua b/bundle/nui.nvim/lua/nui/layout/utils.lua new file mode 100644 index 000000000..bdd21d4b3 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/layout/utils.lua @@ -0,0 +1,216 @@ +local utils = require("nui.utils") + +local _ = utils._ +local defaults = utils.defaults + +--luacheck: push no max line length + +---@alias nui_layout_option_relative_type "'cursor'"|"'editor'"|"'win'"|"'buf'" +---@alias nui_layout_option_relative { type: nui_layout_option_relative_type, winid?: number, position?: { row: number, col: number } } +---@alias nui_layout_option_position { row: number|string, col: number|string } +---@alias nui_layout_option_size { width: number|string, height: number|string } +---@alias nui_layout_config { relative?: nui_layout_option_relative, size?: nui_layout_option_size, position?: nui_layout_option_position } +---@alias nui_layout_internal_position { relative: "'cursor'"|"'editor'"|"'win'", win: number, bufpos?: number[], row: number, col: number } +---@alias nui_layout_container_info { relative: nui_layout_option_relative_type, size: nui_layout_option_size, type: "'editor'"|"'window'" } + +--luacheck: pop + +local mod_size = {} +local mod_position = {} + +local mod = { + size = mod_size, + position = mod_position, +} + +---@param position nui_layout_option_position +---@param size { width: number, height: number } +---@param container nui_layout_container_info +---@return { row: number, col: number } +function mod.calculate_window_position(position, size, container) + local row + local col + + local is_percentage_allowed = not vim.tbl_contains({ "buf", "cursor" }, container.relative) + local percentage_error = string.format("position %% can not be used relative to %s", container.relative) + + local r = utils.parse_number_input(position.row) + assert(r.value ~= nil, "invalid position.row") + if r.is_percentage then + assert(is_percentage_allowed, percentage_error) + row = math.floor((container.size.height - size.height) * r.value) + else + row = r.value + end + + local c = utils.parse_number_input(position.col) + assert(c.value ~= nil, "invalid position.col") + if c.is_percentage then + assert(is_percentage_allowed, percentage_error) + col = math.floor((container.size.width - size.width) * c.value) + else + col = c.value + end + + return { + row = row, + col = col, + } +end + +---@param size { width: number|string, height: number|string } +---@param container_size { width: number, height: number } +---@return { width: number, height: number } +function mod.calculate_window_size(size, container_size) + local width = _.normalize_dimension(size.width, container_size.width) + assert(width, "invalid size.width") + + local height = _.normalize_dimension(size.height, container_size.height) + assert(height, "invalid size.height") + + return { + width = width, + height = height, + } +end + +---@param position nui_layout_internal_position +---@return nui_layout_container_info +function mod.get_container_info(position) + local relative = position.relative + + if relative == "editor" then + return { + relative = relative, + size = utils.get_editor_size(), + type = "editor", + } + end + + if relative == "cursor" or relative == "win" then + return { + relative = position.bufpos and "buf" or relative, + size = utils.get_window_size(position.win), + type = "window", + } + end +end + +---@param relative nui_layout_option_relative +---@param fallback_winid number +---@return nui_layout_internal_position +function mod.parse_relative(relative, fallback_winid) + local winid = defaults(relative.winid, fallback_winid) + + if relative.type == "buf" then + return { + relative = "win", + win = winid, + bufpos = { + relative.position.row, + relative.position.col, + }, + } + end + + return { + relative = relative.type, + win = winid, + } +end + +---@param component_internal table +---@param config nui_layout_config +function mod.update_layout_config(component_internal, config) + local internal = component_internal + + local options = _.normalize_layout_options({ + relative = config.relative, + size = config.size, + position = config.position, + }) + + local win_config = internal.win_config + + if options.relative then + internal.layout.relative = options.relative + + local fallback_winid = internal.position and internal.position.win or vim.api.nvim_get_current_win() + internal.position = vim.tbl_extend( + "force", + internal.position or {}, + mod.parse_relative(internal.layout.relative, fallback_winid) + ) + + win_config.relative = internal.position.relative + win_config.win = internal.position.relative == "win" and internal.position.win or nil + win_config.bufpos = internal.position.bufpos + end + + if not win_config.relative then + return error("missing layout config: relative") + end + + local prev_container_size = internal.container_info and internal.container_info.size + internal.container_info = mod.get_container_info(internal.position) + local container_size_changed = not mod.size.are_same(internal.container_info.size, prev_container_size) + + local need_size_refresh = container_size_changed + and internal.layout.size + and mod.size.contains_percentage_string(internal.layout.size) + + if options.size or need_size_refresh then + internal.layout.size = options.size or internal.layout.size + + internal.size = mod.calculate_window_size(internal.layout.size, internal.container_info.size) + + win_config.width = internal.size.width + win_config.height = internal.size.height + end + + if not win_config.width or not win_config.height then + return error("missing layout config: size") + end + + local need_position_refresh = container_size_changed + and internal.layout.position + and mod.position.contains_percentage_string(internal.layout.position) + + if options.position or need_position_refresh then + internal.layout.position = options.position or internal.layout.position + + internal.position = vim.tbl_extend( + "force", + internal.position, + mod.calculate_window_position(internal.layout.position, internal.size, internal.container_info) + ) + + win_config.row = internal.position.row + win_config.col = internal.position.col + end + + if not win_config.row or not win_config.col then + return error("missing layout config: position") + end +end + +---@param size_a nui_layout_option_size +---@param size_b? nui_layout_option_size +---@return boolean +function mod_size.are_same(size_a, size_b) + return size_b and size_a.width == size_b.width and size_a.height == size_b.height +end + +---@param size nui_layout_option_size +---@return boolean +function mod_size.contains_percentage_string(size) + return type(size.width) == "string" or type(size.height) == "string" +end + +---@param position nui_layout_option_position +---@return boolean +function mod_position.contains_percentage_string(position) + return type(position.row) == "string" or type(position.col) == "string" +end + +return mod diff --git a/bundle/nui.nvim/lua/nui/line/README.md b/bundle/nui.nvim/lua/nui/line/README.md new file mode 100644 index 000000000..77561e229 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/line/README.md @@ -0,0 +1,100 @@ +# NuiLine + +NuiLine is an abstraction layer on top of the following native functions: + +- `vim.api.nvim_buf_set_lines` (check `:h nvim_buf_set_lines()`) +- `vim.api.nvim_buf_set_text` (check `:h nvim_buf_set_text()`) +- `vim.api.nvim_buf_add_highlight` (check `:h nvim_buf_add_highlight()`) + +It helps you create line on the buffer containing multiple [`NuiText`](../text)s. + +_Signature:_ `NuiLine(texts?)` + +**Example** + +```lua +local NuiLine = require("nui.line") + +local line = NuiLine() + +line:append("Something Went Wrong!", "Error") + +local bufnr, ns_id, linenr_start = 0, -1, 1 + +line:render(bufnr, ns_id, linenr_start) +``` + +## Parameters + +### `texts` + +**Type:** `table[]` + +List of `NuiText` objects to set as initial texts. + +**Example** + +```lua +local text_one = NuiText("One") +local text_two = NuiText("Two") +local line = NuiLine({ text_one, text_two }) +``` + +## Methods + +### `line:append` + +_Signature:_ `line:append(content, highlight?)` + +Adds a chunk of content to the line. + +**Parameters** + +| Name | Type | Description | +| ----------- | -------------------------------- | --------------------- | +| `content` | `string` / `NuiText` / `NuiLine` | content | +| `highlight` | `string` or `table` | highlight information | + +If `text` is `string`, these parameters are passed to `NuiText` +and a `NuiText` object is returned. + +It `content` is a `NuiText`/`NuiLine` object, it is returned unchanged. + +### `line:content` + +_Signature:_ `line:content()` + +Returns the line content. + +### `line:highlight` + +_Signature:_ `line:highlight(bufnr, ns_id, linenr)` + +Applies highlight for the line. + +**Parameters** + +| Name | Type | Description | +| -------- | -------- | ---------------------------------------------- | +| `bufnr` | `number` | buffer number | +| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) | +| `linenr` | `number` | line number (1-indexed) | + +### `line:render` + +_Signature:_ `line:render(bufnr, ns_id, linenr_start, linenr_end?)` + +Sets the line on buffer and applies highlight. + +**Parameters** + +| Name | Type | Description | +| -------------- | -------- | ---------------------------------------------- | +| `bufnr` | `number` | buffer number | +| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) | +| `linenr_start` | `number` | start line number (1-indexed) | +| `linenr_end` | `number` | end line number (1-indexed) | + +## Wiki Page + +You can find additional documentation/examples/guides/tips-n-tricks in [nui.line wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.line). diff --git a/bundle/nui.nvim/lua/nui/line/init.lua b/bundle/nui.nvim/lua/nui/line/init.lua new file mode 100644 index 000000000..78b9a8378 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/line/init.lua @@ -0,0 +1,77 @@ +local Object = require("nui.object") +local NuiText = require("nui.text") +local defaults = require("nui.utils").defaults +local is_type = require("nui.utils").is_type + +---@class NuiLine +---@field _texts NuiText[] +local Line = Object("NuiLine") + +---@param texts? table[] NuiText objects +function Line:init(texts) + self._texts = defaults(texts, {}) +end + +---@param content string|NuiText|NuiLine +---@param highlight? string|table data for highlight +---@return NuiText|NuiLine +function Line:append(content, highlight) + local block = is_type("string", content) and NuiText(content, highlight) or content + if block._texts then + ---@cast block NuiLine + for _, text in ipairs(block._texts) do + table.insert(self._texts, text) + end + else + ---@cast block NuiText + table.insert(self._texts, block) + end + return block +end + +---@return string +function Line:content() + return table.concat(vim.tbl_map(function(text) + return text:content() + end, self._texts)) +end + +---@return number +function Line:width() + local width = 0 + for _, text in ipairs(self._texts) do + width = width + text:width() + end + return width +end + +---@param bufnr number buffer number +---@param ns_id number namespace id +---@param linenr number line number (1-indexed) +---@return nil +function Line:highlight(bufnr, ns_id, linenr) + local current_byte_start = 0 + for _, text in ipairs(self._texts) do + text:highlight(bufnr, ns_id, linenr, current_byte_start) + current_byte_start = current_byte_start + text:length() + end +end + +---@param bufnr number buffer number +---@param ns_id number namespace id +---@param linenr_start number start line number (1-indexed) +---@param linenr_end? number end line number (1-indexed) +---@return nil +function Line:render(bufnr, ns_id, linenr_start, linenr_end) + local row_start = linenr_start - 1 + local row_end = linenr_end and linenr_end - 1 or row_start + 1 + local content = self:content() + vim.api.nvim_buf_set_lines(bufnr, row_start, row_end, false, { content }) + self:highlight(bufnr, ns_id, linenr_start) +end + +---@alias NuiLine.constructor fun(texts?: NuiText[]): NuiLine +---@type NuiLine|NuiLine.constructor +local NuiLine = Line + +return NuiLine diff --git a/bundle/nui.nvim/lua/nui/menu/README.md b/bundle/nui.nvim/lua/nui/menu/README.md new file mode 100644 index 000000000..6f9fe2fda --- /dev/null +++ b/bundle/nui.nvim/lua/nui/menu/README.md @@ -0,0 +1,207 @@ +# Menu + +`Menu` is abstraction layer on top of `Popup`. + +```lua +local Menu = require("nui.menu") +local event = require("nui.utils.autocmd").event + +local popup_options = { + relative = "cursor", + position = { + row = 1, + col = 0, + }, + border = { + style = "rounded", + text = { + top = "[Choose Item]", + top_align = "center", + }, + }, + win_options = { + winhighlight = "Normal:Normal", + } +} + +local menu = Menu(popup_options, { + lines = { + Menu.separator("Group One"), + Menu.item("Item 1"), + Menu.item("Item 2"), + Menu.separator("Group Two", { + char = "-", + text_align = "right", + }), + Menu.item("Item 3"), + Menu.item("Item 4"), + }, + max_width = 20, + keymap = { + focus_next = { "j", "", "" }, + focus_prev = { "k", "", "" }, + close = { "", "" }, + submit = { "", "" }, + }, + on_close = function() + print("CLOSED") + end, + on_submit = function(item) + print("SUBMITTED", vim.inspect(item)) + end, +}) +``` + +You can manipulate the assocciated buffer and window using the +`split.bufnr` and `split.winid` properties. + +**NOTE**: the first argument accepts options for `nui.popup` component. + +## Options + +### `lines` + +**Type:** `table` + +List of menu items. + +**`Menu.item(content, data?)`** + +`Menu.item` is used to create an item object for the `Menu`. + +**Parameters** + +| Name | Type | +| --------- | -------------------------------- | +| `content` | `string` / `NuiText` / `NuiLine` | +| `data` | `table` / `nil` | + +**Example** + +```lua +Menu.item("One") --> { text = "One" } + +Menu.item("Two", { id = 2 }) --> { id = 2, text = "Two" } +``` + +This is what you get as the argument of `on_submit` callback function. +You can include whatever you want in the item object. + +**`Menu.separator(content?, options?)`** + +`Menu.separator` is used to create a menu item that can't be focused. + +**Parameters** + +| Name | Type | +| --------- | ---------------------------------------------------------------------------------- | +| `content` | `string` / `NuiText` / `NuiLine` / `nil` | +| `options` | `{ char?: string\|NuiText, text_align?: "'left'"\|"'center'"\|"'right'" }` / `nil` | + +You can just use `Menu.item` only and implement `Menu.separator`'s behavior +by providing a custom `should_skip_item` function. + +### `prepare_item` + +**Type:** `function` + +_Signature:_ `prepare_item(item)` + +If provided, this function is used for preparing each menu item. + +The return value should be a `NuiLine` object or `string` or a list containing either of them. + +If return value is `nil`, that node will not be rendered. + +### `should_skip_item` + +**Type:** `function` + +_Signature:_ `should_skip_item(item)` + +If provided, this function is used to determine if an item should be +skipped when focusing previous/next item. + +The return value should be `boolean`. + +By default, items created by `Menu.separator` are skipped. + +### `max_height` + +**Type:** `number` + +Maximum height of the menu. + +### `min_height` + +**Type:** `number` + +Minimum height of the menu. + +### `max_width` + +**Type:** `number` + +Maximum width of the menu. + +### `min_width` + +**Type:** `number` + +Minimum width of the menu. + +### `keymap` + +**Type:** `table` + +Key mappings for the menu. + +**Example** + +```lua +keymap = { + close = { "", "" }, + focus_next = { "j", "", "" }, + focus_prev = { "k", "", "" }, + submit = { "" }, +}, +``` + +### `on_change` + +**Type:** `function` + +_Signature:_ `on_change(item, menu)` + +Callback function, called when menu item is focused. + +### `on_close` + +**Type:** `function` + +_Signature:_ `on_close(item, menu)` + +Callback function, called when menu is closed. + +### `on_submit` + +**Type:** `function` + +_Signature:_ `on_submit(item)` + +Callback function, called when menu is submitted. + +## Methods + +Methods from `nui.popup` are also available for `nui.menu`. + +## Properties + +### `menu.tree` + +The underlying `NuiTree` object used for rendering the menu. You can use it to +manipulate the menu items on-the-fly and access all the `NuiTree` methods. + +## Wiki Page + +You can find additional documentation/examples/guides/tips-n-tricks in [nui.menu wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.menu). diff --git a/bundle/nui.nvim/lua/nui/menu/init.lua b/bundle/nui.nvim/lua/nui/menu/init.lua new file mode 100644 index 000000000..2e038107c --- /dev/null +++ b/bundle/nui.nvim/lua/nui/menu/init.lua @@ -0,0 +1,353 @@ +local Line = require("nui.line") +local Popup = require("nui.popup") +local Text = require("nui.text") +local Tree = require("nui.tree") +local _ = require("nui.utils")._ +local defaults = require("nui.utils").defaults +local is_type = require("nui.utils").is_type + +local function calculate_initial_max_width(items) + local max_width = 0 + + for _, item in ipairs(items) do + local width = 0 + if is_type("string", item.text) then + width = vim.api.nvim_strwidth(item.text) + elseif is_type("table", item.text) and item.text.width then + width = item.text:width() + end + + if max_width < width then + max_width = width + end + end + + return max_width +end + +local default_keymap = { + close = { "", "" }, + focus_next = { "j", "", "" }, + focus_prev = { "k", "", "" }, + submit = { "" }, +} + +---@param keymap table +---@return table +local function parse_keymap(keymap) + local result = defaults(keymap, {}) + + for name, default_keys in pairs(default_keymap) do + if is_type("nil", result[name]) then + result[name] = default_keys + elseif is_type("string", result[name]) then + result[name] = { result[name] } + end + end + + return result +end + +---@type nui_menu_should_skip_item +local function default_should_skip_item(node) + return node._type == "separator" +end + +---@param menu NuiMenu +---@return nui_menu_prepare_item +local function make_default_prepare_node(menu) + local border = menu.border + + local fallback_sep = { + char = Text(is_type("table", border._.char) and border._.char.top or " "), + text_align = is_type("table", border._.text) and border._.text.top_align or "left", + } + + -- luacov: disable + if menu._.sep then + -- @deprecated + + if menu._.sep.char then + fallback_sep.char = Text(menu._.sep.char) + end + + if menu._.sep.text_align then + fallback_sep.text_align = menu._.sep.text_align + end + end + -- luacov: enable + + local max_width = menu._.size.width + + ---@type nui_menu_prepare_item + local function default_prepare_node(node) + ---@type NuiText|NuiLine + local content = is_type("string", node.text) and Text(node.text) or node.text + + if node._type == "item" then + if content:width() > max_width then + if is_type("function", content.set) then + ---@cast content NuiText + _.truncate_nui_text(content, max_width) + else + ---@cast content NuiLine + _.truncate_nui_line(content, max_width) + end + end + + local line = Line() + + line:append(content) + + return line + end + + if node._type == "separator" then + local sep_char = Text(defaults(node._char, fallback_sep.char)) + local sep_text_align = defaults(node._text_align, fallback_sep.text_align) + + local sep_max_width = max_width - sep_char:width() * 2 + + if content:width() > sep_max_width then + if is_type("function", content.set) then + ---@cast content NuiText + _.truncate_nui_text(content, sep_max_width) + else + ---@cast content NuiLine + _.truncate_nui_line(content, sep_max_width) + end + end + + local left_gap_width, right_gap_width = _.calculate_gap_width( + defaults(sep_text_align, "center"), + sep_max_width, + content:width() + ) + + local line = Line() + + line:append(Text(sep_char)) + + if left_gap_width > 0 then + line:append(Text(sep_char):set(string.rep(sep_char:content(), left_gap_width))) + end + + line:append(content) + + if right_gap_width > 0 then + line:append(Text(sep_char):set(string.rep(sep_char:content(), right_gap_width))) + end + + line:append(Text(sep_char)) + + return line + end + end + + return default_prepare_node +end + +---@param menu NuiMenu +---@param direction "'next'" | "'prev'" +---@param current_linenr nil | number +local function focus_item(menu, direction, current_linenr) + local curr_linenr = current_linenr or vim.api.nvim_win_get_cursor(menu.winid)[1] + + local next_linenr = nil + + if direction == "next" then + if curr_linenr == #menu.tree.nodes.root_ids then + next_linenr = 1 + else + next_linenr = curr_linenr + 1 + end + elseif direction == "prev" then + if curr_linenr == 1 then + next_linenr = #menu.tree.nodes.root_ids + else + next_linenr = curr_linenr - 1 + end + end + + local next_node = menu.tree:get_node(next_linenr) + + if menu._.should_skip_item(next_node) then + return focus_item(menu, direction, next_linenr) + end + + if next_linenr then + vim.api.nvim_win_set_cursor(menu.winid, { next_linenr, 0 }) + menu._.on_change(next_node) + end +end + +--luacheck: push no max line length + +---@alias nui_menu_prepare_item nui_tree_prepare_node +---@alias nui_menu_should_skip_item fun(node: NuiTreeNode): boolean +---@alias nui_menu_internal nui_popup_internal|{ items: NuiTreeNode[], keymap: table, sep: { char?: string|NuiText, text_align?: nui_t_text_align }, prepare_item: nui_menu_prepare_item, should_skip_item: nui_menu_should_skip_item } + +--luacheck: pop + +---@class NuiMenu: NuiPopup +---@field private _ nui_menu_internal +local Menu = Popup:extend("NuiMenu") + +---@param content? string|NuiText|NuiLine +---@param options? { char?: string|NuiText, text_align?: nui_t_text_align } +---@return NuiTreeNode +function Menu.separator(content, options) + options = options or {} + return Tree.Node({ + _id = tostring(math.random()), + _type = "separator", + _char = options.char, + _text_align = options.text_align, + text = defaults(content, ""), + }) +end + +---@param content string|NuiText|NuiLine +---@param data? table +---@return NuiTreeNode +function Menu.item(content, data) + if not data then + ---@diagnostic disable-next-line: undefined-field + if is_type("table", content) and content.text then + ---@cast content table + data = content + else + data = { text = content } + end + else + data.text = content + end + + data._type = "item" + data._id = data.id or tostring(math.random()) + + return Tree.Node(data) +end + +---@param popup_options table +---@param options table +function Menu:init(popup_options, options) + local max_width = calculate_initial_max_width(options.lines) + + local width = math.max(math.min(max_width, defaults(options.max_width, 256)), defaults(options.min_width, 4)) + local height = math.max(math.min(#options.lines, defaults(options.max_height, 256)), defaults(options.min_height, 1)) + + popup_options = vim.tbl_deep_extend("force", { + enter = true, + size = { + width = width, + height = height, + }, + win_options = { + cursorline = true, + scrolloff = 1, + sidescrolloff = 0, + }, + }, popup_options) + + Menu.super.init(self, popup_options) + + self._.items = options.lines + self._.keymap = parse_keymap(options.keymap) + + ---@param node NuiTreeNode + self._.on_change = function(node) + if options.on_change then + options.on_change(node, self) + end + end + + -- @deprecated + self._.sep = options.separator + + self._.should_skip_item = defaults(options.should_skip_item, default_should_skip_item) + self._.prepare_item = defaults(options.prepare_item, make_default_prepare_node(self)) + + self.menu_props = {} + + local props = self.menu_props + + props.on_submit = function() + local item = self.tree:get_node() + + self:unmount() + + if options.on_submit then + options.on_submit(item) + end + end + + props.on_close = function() + self:unmount() + + if options.on_close then + options.on_close() + end + end + + props.on_focus_next = function() + focus_item(self, "next") + end + + props.on_focus_prev = function() + focus_item(self, "prev") + end +end + +function Menu:mount() + Menu.super.mount(self) + + local props = self.menu_props + + for _, key in pairs(self._.keymap.focus_next) do + self:map("n", key, props.on_focus_next, { noremap = true, nowait = true }) + end + + for _, key in pairs(self._.keymap.focus_prev) do + self:map("n", key, props.on_focus_prev, { noremap = true, nowait = true }) + end + + for _, key in pairs(self._.keymap.close) do + self:map("n", key, props.on_close, { noremap = true, nowait = true }) + end + + for _, key in pairs(self._.keymap.submit) do + self:map("n", key, props.on_submit, { noremap = true, nowait = true }) + end + + self.tree = Tree({ + winid = self.winid, + ns_id = self.ns_id, + nodes = self._.items, + get_node_id = function(node) + return node._id + end, + prepare_node = self._.prepare_item, + }) + + ---@deprecated + self._tree = self.tree + + self.tree:render() + + -- focus first item + for linenr = 1, #self.tree.nodes.root_ids do + local node, target_linenr = self.tree:get_node(linenr) + if not self._.should_skip_item(node) then + vim.api.nvim_win_set_cursor(self.winid, { target_linenr, 0 }) + self._.on_change(node) + break + end + end +end + +---@alias NuiMenu.constructor fun(popup_options: table, options: table): NuiMenu +---@type NuiMenu|NuiMenu.constructor +local NuiMenu = Menu + +return NuiMenu diff --git a/bundle/nui.nvim/lua/nui/object/init.lua b/bundle/nui.nvim/lua/nui/object/init.lua new file mode 100644 index 000000000..716a2e49c --- /dev/null +++ b/bundle/nui.nvim/lua/nui/object/init.lua @@ -0,0 +1,170 @@ +-- source: https://github.com/kikito/middleclass + +local idx = { + subclasses = { "" }, +} + +local function __tostring(self) + return "class " .. self.name +end + +local function __call(self, ...) + return self:new(...) +end + +local function create_index_wrapper(class, index) + if type(index) == "table" then + return function(self, key) + local value = self.class.__meta[key] + if value == nil then + return index[key] + end + return value + end + elseif type(index) == "function" then + return function(self, key) + local value = self.class.__meta[key] + if value == nil then + return index(self, key) + end + return value + end + else + return class.__meta + end +end + +local function propagate_instance_property(class, key, value) + value = key == "__index" and create_index_wrapper(class, value) or value + + class.__meta[key] = value + + for subclass in pairs(class[idx.subclasses]) do + if subclass.__properties[key] == nil then + propagate_instance_property(subclass, key, value) + end + end +end + +local function declare_instance_property(class, key, value) + class.__properties[key] = value + + if value == nil and class.super then + value = class.super.__meta[key] + end + + propagate_instance_property(class, key, value) +end + +local function is_subclass(subclass, class) + if not subclass.super then + return false + end + if subclass.super == class then + return true + end + return is_subclass(subclass.super, class) +end + +local function is_instance(instance, class) + if instance.class == class then + return true + end + return is_subclass(instance.class, class) +end + +local function create_class(name, super) + assert(name, "missing name") + + local meta = { + is_instance_of = is_instance, + } + meta.__index = meta + + local class = { + super = super, + name = name, + static = { + is_subclass_of = is_subclass, + }, + + [idx.subclasses] = setmetatable({}, { __mode = "k" }), + + __meta = meta, + __properties = {}, + } + + setmetatable(class.static, { + __index = function(_, key) + local value = rawget(class.__meta, key) + if value == nil and super then + return super.static[key] + end + return value + end, + }) + + setmetatable(class, { + __call = __call, + __index = class.static, + __name = class.name, + __newindex = declare_instance_property, + __tostring = __tostring, + }) + + return class +end + +---@param name string +local function create_object(_, name) + local Class = create_class(name) + + ---@return string + function Class:__tostring() + return "instance of " .. tostring(self.class) + end + + ---@return nil + function Class:init() end -- luacheck: no unused args + + function Class.static:new(...) + local instance = setmetatable({ class = self }, self.__meta) + instance:init(...) + return instance + end + + ---@param name string + function Class.static:extend(name) -- luacheck: no redefined + local subclass = create_class(name, self) + + for key, value in pairs(self.__meta) do + if not (key == "__index" and type(value) == "table") then + propagate_instance_property(subclass, key, value) + end + end + + function subclass.init(instance, ...) + self.init(instance, ...) + end + + self[idx.subclasses][subclass] = true + + return subclass + end + + return Class +end + +--luacheck: push no max line length + +---@type (fun(name: string): table)|{ is_subclass: (fun(subclass: table, class: table): boolean), is_instance: (fun(instance: table, class: table): boolean) } +local Object = setmetatable({ + is_subclass = is_subclass, + is_instance = is_instance, +}, { + __call = create_object, +}) + +--luacheck: pop + +return Object diff --git a/bundle/nui.nvim/lua/nui/popup/README.md b/bundle/nui.nvim/lua/nui/popup/README.md new file mode 100644 index 000000000..916d729d0 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/popup/README.md @@ -0,0 +1,631 @@ +# Popup + +Popup is an abstraction layer on top of window. + +Creates a new popup object (but does not mount it immediately). + +**Examples** + +```lua +local Popup = require("nui.popup") + +local popup = Popup({ + position = "50%", + size = { + width = 80, + height = 40, + }, + enter = true, + focusable = true, + zindex = 50, + relative = "editor", + border = { + padding = { + top = 2, + bottom = 2, + left = 3, + right = 3, + }, + style = "rounded", + text = { + top = " I am top title ", + top_align = "center", + bottom = "I am bottom title", + bottom_align = "left", + }, + }, + buf_options = { + modifiable = true, + readonly = false, + }, + win_options = { + winblend = 10, + winhighlight = "Normal:Normal,FloatBorder:FloatBorder", + }, +}) +``` + +You can manipulate the assocciated buffer and window using the +`split.bufnr` and `split.winid` properties. + +## Options + +### `border` + +**Type:** `table` + +Contains all border related options. + +#### `border.padding` + +**Type:** `table` + +Controls the popup padding. + +**Examples** + +It can be a list (`table`) with number of cells for top, right, bottom and left. +The order behaves like [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS) padding. + +```lua +border = { + -- `1` for top/bottom and `2` for left/right + padding = { 1, 2 }, +}, +``` + +You can also use a map (`table`) to set padding at specific side: + +```lua +border = { + -- `1` for top, `2` for left, `0` for other sides + padding = { + top = 1, + left = 2, + }, +}, +``` + +#### `border.style` + +**Type:** `string` or `table` + +Controls the styling of the border. + +**Examples** + +Can be one of the pre-defined styles: `"double"`, `"none"`, `"rounded"`, `"shadow"`, `"single"` or `"solid"`. + +```lua +border = { + style = "double", +}, +``` + +List (`table`) of characters starting from the top-left corner and then clockwise: + +```lua +border = { + style = { "╭", "─", "╮", "│", "╯", "─", "╰", "│" }, +}, +``` + +Map (`table`) with named characters: + +```lua +border = { + style = { + top_left = "╭", top = "─", top_right = "╮", + left = "│", right = "│", + bottom_left = "╰", bottom = "─", bottom_right = "╯", + }, +}, +``` + +If you don't need all these options, you can also pass the value of `border.style` to `border` +directly. + +To set the highlight group for all the border characters, use the `win_options.winhighlight` +option and include the name of highlight group for `FloatBorder`. + +**Examples** + +```lua +win_options = { + winhighlight = "Normal:Normal,FloatBorder:SpecialChar", +}, +``` + +To set the highlight group for individual border character, you can use `NuiText` or a tuple +with `(char, hl_group)`. + +**Examples** + +```lua +border = { + style = { { [[/]], "SpecialChar" }, [[─]], NuiText([[\]], "SpecialChar"), [[│]] }, +}, +``` + +#### `border.text` + +**Type:** `table` + +Text displayed on the border (as title/footnote). + +| Key | Type | Description | +| ---------------- | -------------------------------------------- | ---------------------------- | +| `"top"` | `string` / `NuiText` | top border text | +| `"top_align"` | `"left"` / `"right"`/ `"center"` _(default)_ | top border text alignment | +| `"bottom"` | `string` / `NuiText` | bottom border text | +| `"bottom_align"` | `"left"` / `"right"`/ `"center"` _(default)_ | bottom border text alignment | + +**Examples** + +```lua +border = { + text = { + top = "Popup Title", + top_align = "center", + }, +}, +``` + +--- + +### `ns_id` + +**Type:** `number` or `string` + +Namespace id (`number`) or name (`string`). + +--- + +### `relative` + +**Type:** `string` or `table` + +This option affects how `position` and `size` are calculated. + +**Examples** + +Relative to cursor on current window: + +```lua +relative = "cursor", +``` + +Relative to the current editor screen: + +```lua +relative = "editor", +``` + +Relative to the current window (_default_): + +```lua +relative = "win", +``` + +Relative to the window with specific id: + +```lua +relative = { + type = "win", + winid = 5, +}, +``` + +Relative to the buffer position: + +```lua +relative = { + type = "buf", + -- zero-indexed + position = { + row = 5, + col = 5, + }, +}, +``` + +--- + +### `position` + +**Type:** `number` or `percentage string` or `table` + +Position is calculated from the top-left corner. + +If `position` is `number` or `percentage string`, it applies to both `row` and `col`. +Or you can pass a table to set them separately. + +For `percentage string`, position is calculated according to the option `relative`. +If `relative` is set to `"buf"` or `"cursor"`, `percentage string` is not allowed. + +**Examples** + +```lua +position = 50, +``` + +```lua +position = "50%", +``` + +```lua +position = { + row = 30, + col = 20, +}, +``` + +```lua +position = { + row = "20%", + col = "50%", +}, +``` + +--- + +### `size` + +**Type:** `number` or `percentage string` or `table` + +Determines the size of the popup. + +If `size` is `number` or `percentage string`, it applies to both `width` and `height`. +You can also pass a table to set them separately. + +For `percentage string`, `size` is calculated according to the option `relative`. +If `relative` is set to `"buf"` or `"cursor"`, window size is considered. + +**Examples** + +```lua +size = 50, +``` + +```lua +size = "50%", +``` + +```lua +size = { + width = 80, + height = 40, +}, +``` + +```lua +size = { + width = "80%", + height = "60%", +}, +``` + +--- + +### `enter` + +**Type:** `boolean` + +If `true`, the popup is entered immediately after mount. + +**Examples** + +```lua +enter = true, +``` + +--- + +### `focusable` + +**Type:** `boolean` + +If `false`, the popup can not be entered by user actions (wincmds, mouse events). + +**Examples** + +```lua +focusable = true, +``` + +--- + +### `zindex` + +**Type:** `number` + +Sets the order of the popup on z-axis. + +Popup with higher the `zindex` goes on top of popups with lower `zindex`. + +**Examples** + +```lua +zindex = 50, +``` + +--- + +### `buf_options` + +**Type:** `table` + +Contains all buffer related options (check `:h options | /local to buffer`). + +**Examples** + +```lua +buf_options = { + modifiable = false, + readonly = true, +}, +``` + +--- + +### `win_options` + +**Type:** `table` + +Contains all window related options (check `:h options | /local to window`). + +**Examples** + +```lua +win_options = { + winblend = 10, + winhighlight = "Normal:Normal,FloatBorder:FloatBorder", +}, +``` + +--- + +### `bufnr` + +**Type:** `number` + +You can pass `bufnr` of an existing buffer to display it on the popup. + +**Examples:** + +```lua +bufnr = vim.api.nvim_get_current_buf(), +``` + +## Methods + +### `popup:mount` + +_Signature:_ `popup:mount()` + +Mounts the popup. + +**Examples** + +```lua +popup:mount() +``` + +--- + +### `popup:unmount` + +_Signature:_ `popup:unmount()` + +Unmounts the popup. + +**Examples** + +```lua +popup:unmount() +``` + +--- + +### `popup:hide` + +_Signature:_ `popup:hide()` + +Hides the popup window. Preserves the buffer (related content, autocmds and keymaps). + +--- + +### `popup:show` + +_Signature:_ `popup:show()` + +Shows the hidden popup window. + +--- + +### `popup:map` + +_Signature:_ `popup:map(mode, key, handler, opts) -> nil` + +Sets keymap for the popup. + +**Parameters** + +| Name | Type | Description | +| --------- | --------------------- | --------------------------------------------------------------------------- | +| `mode` | `string` | check `:h :map-modes` | +| `key` | `string` | key for the mapping | +| `handler` | `string` / `function` | handler for the mapping | +| `opts` | `table` | check `:h :map-arguments` (including `remap`/`noremap`, excluding `buffer`) | + +**Examples** + +```lua +local ok = popup:map("n", "", function(bufnr) + print("ESC pressed in Normal mode!") +end, { noremap = true }) +``` + +--- + +### `popup:unmap` + +_Signature:_ `popup:unmap(mode, key) -> nil` + +Deletes keymap for the popup. + +**Parameters** + +| Name | Type | Description | +| ------ | ------------- | --------------------- | +| `mode` | `"n"` / `"i"` | check `:h :map-modes` | +| `key` | `string` | key for the mapping | + +**Examples** + +```lua +local ok = popup:unmap("n", "") +``` + +--- + +### `popup:on` + +_Signature:_ `popup:on(event, handler, options)` + +Defines `autocmd` to run on specific events for this popup. + +**Parameters** + +| Name | Type | Description | +| --------- | --------------------- | ------------------------------------------ | +| `event` | `string[]` / `string` | check `:h events` | +| `handler` | `function` | handler function for event | +| `options` | `table` | keys `once`, `nested` and values `boolean` | + +**Examples** + +```lua +local event = require("nui.utils.autocmd").event + +popup:on({ event.BufLeave }, function() + popup:unmount() +end, { once = true }) +``` + +`event` can be expressed as any of the followings: + +```lua +{ event.BufLeave, event.BufDelete } +-- or +{ event.BufLeave, "BufDelete" } +-- or +event.BufLeave +-- or +"BufLeave" +-- or +"BufLeave,BufDelete" +``` + +--- + +### `popup:off` + +_Signature:_ `popup:off(event)` + +Removes `autocmd` defined with `popup:on({ ... })` + +**Parameters** + +| Name | Type | Description | +| ------- | --------------------- | ----------------- | +| `event` | `string[]` / `string` | check `:h events` | + +**Examples** + +```lua +popup:off("*") +``` + +--- + +### `popup:update_layout` + +_Signature:_ `popup:update_layout(config)` + +Sets the layout of the popup. You can use this method to change popup's +size or position after it's mounted. + +**Parameters** + +`config` is a `table` having the following keys: + +| Key | Type | +| ---------- | ------------------ | +| `relative` | `string` / `table` | +| `position` | `string` / `table` | +| `size` | `string` / `table` | + +They are the same options used for popup initialization. + +**Examples** + +```lua +popup:update_layout({ + relative = "win", + size = { + width = 80, + height = 40, + }, + position = { + row = 30, + col = 20, + }, +}) +``` + +--- + +### `popup.border:set_highlight` + +_Signature:_ `popup.border:set_highlight(highlight: string) -> nil` + +Sets border highlight. + +**Parameters** + +| Name | Type | Description | +| ----------- | -------- | -------------------- | +| `highlight` | `string` | highlight group name | + +**Examples** + +```lua +popup.border:set_highlight("SpecialChar") +``` + +--- + +### `popup.border:set_text` + +_Signature:_ `popup.border:set_text(edge, text, align)` + +Sets border text. + +**Parameters** + +| Name | Type | +| ------- | ------------------------------------------- | +| `edge` | `"top"` / `"bottom"` / `"left"` / `"right"` | +| `text` | `string` | +| `align` | `"left"` / `"right"`/ `"center"` | + +**Examples** + +```lua +popup.border:set_text("bottom", "[Progress: 42%]", "right") +``` + +## Wiki Page + +You can find additional documentation/examples/guides/tips-n-tricks in [nui.popup wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.popup). diff --git a/bundle/nui.nvim/lua/nui/popup/border.lua b/bundle/nui.nvim/lua/nui/popup/border.lua new file mode 100644 index 000000000..62512b844 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/popup/border.lua @@ -0,0 +1,635 @@ +local Object = require("nui.object") +local Line = require("nui.line") +local Text = require("nui.text") +local _ = require("nui.utils")._ +local defaults = require("nui.utils").defaults +local is_type = require("nui.utils").is_type + +local u = { + clear_namespace = _.clear_namespace, +} + +local has_nvim_0_5_1 = vim.fn.has("nvim-0.5.1") == 1 + +local index_name = { + "top_left", + "top", + "top_right", + "right", + "bottom_right", + "bottom", + "bottom_left", + "left", +} + +local function to_border_map(border) + -- fillup all 8 characters + local count = vim.tbl_count(border) + if count < 8 then + for i = count + 1, 8 do + local fallback_index = i % count + local char = border[fallback_index == 0 and count or fallback_index] + if is_type("table", char) then + char = char.content and Text(char) or vim.deepcopy(char) + end + border[i] = char + end + end + + local named_border = {} + + for index, name in ipairs(index_name) do + named_border[name] = border[index] + end + + return named_border +end + +local function to_border_list(named_border) + local border = {} + + for index, name in ipairs(index_name) do + if is_type("nil", named_border[name]) then + error(string.format("missing named border: %s", name)) + end + + border[index] = named_border[name] + end + + return border +end + +---@param internal nui_popup_border_internal +local function normalize_border_char(internal) + if not internal.char or is_type("string", internal.char) then + return internal.char + end + + if internal.type == "simple" then + for position, item in pairs(internal.char) do + if is_type("string", item) then + internal.char[position] = item + elseif item.content then + if item.extmark and item.extmark.hl_group then + internal.char[position] = { item:content(), item.extmark.hl_group } + else + internal.char[position] = item:content() + end + else + internal.char[position] = item + end + end + + return internal.char + end + + for position, item in pairs(internal.char) do + if is_type("string", item) then + internal.char[position] = Text(item, "FloatBorder") + elseif not item.content then + internal.char[position] = Text(item[1], item[2] or "FloatBorder") + elseif item.extmark then + item.extmark.hl_group = item.extmark.hl_group or "FloatBorder" + else + item.extmark = { hl_group = "FloatBorder" } + end + end + + return internal.char +end + +---@param text? nil | string | NuiText +local function normalize_border_text(text) + if not text then + return text + end + + if is_type("string", text) then + return Text(text, "FloatTitle") + end + + text.extmark = vim.tbl_deep_extend("keep", text.extmark or {}, { + hl_group = "FloatTitle", + }) + + return text +end + +---@param internal nui_popup_border_internal +---@param popup_winhighlight? string +local function calculate_winhighlight(internal, popup_winhighlight) + if internal.type == "simple" then + return + end + + local winhl = popup_winhighlight + + -- @deprecated + if internal.highlight then + if not string.match(internal.highlight, ":") then + internal.highlight = "FloatBorder:" .. internal.highlight + end + + winhl = internal.highlight + internal.highlight = nil + end + + return winhl +end + +---@return nui_popup_border_internal_padding|nil +local function parse_padding(padding) + if not padding then + return nil + end + + if is_type("map", padding) then + return padding + end + + local map = {} + map.top = defaults(padding[1], 0) + map.right = defaults(padding[2], map.top) + map.bottom = defaults(padding[3], map.top) + map.left = defaults(padding[4], map.right) + return map +end + +---@param edge "'top'" | "'bottom'" +---@param text? nil | string | NuiText +---@param align? nil | "'left'" | "'center'" | "'right'" +---@return table NuiLine +local function calculate_buf_edge_line(internal, edge, text, align) + local char, size = internal.char, internal.size + + local left_char = char[edge .. "_left"] + local mid_char = char[edge] + local right_char = char[edge .. "_right"] + + if left_char:content() == "" then + left_char = Text(mid_char:content() == "" and char["left"] or mid_char) + end + + if right_char:content() == "" then + right_char = Text(mid_char:content() == "" and char["right"] or mid_char) + end + + local max_width = size.width - left_char:width() - right_char:width() + + local content_text = Text(defaults(text, "")) + if mid_char:width() == 0 then + content_text:set(string.rep(" ", max_width)) + else + content_text:set(_.truncate_text(content_text:content(), max_width)) + end + + local left_gap_width, right_gap_width = _.calculate_gap_width( + defaults(align, "center"), + max_width, + content_text:width() + ) + + local line = Line() + + line:append(left_char) + + if left_gap_width > 0 then + line:append(Text(mid_char):set(string.rep(mid_char:content(), left_gap_width))) + end + + line:append(content_text) + + if right_gap_width > 0 then + line:append(Text(mid_char):set(string.rep(mid_char:content(), right_gap_width))) + end + + line:append(right_char) + + return line +end + +---@return nil | table[] # NuiLine[] +local function calculate_buf_lines(internal) + local char, size, text = internal.char, internal.size, defaults(internal.text, {}) + + if is_type("string", char) then + return nil + end + + local left_char, right_char = char.left, char.right + + local gap_length = size.width - left_char:width() - right_char:width() + + local lines = {} + + table.insert(lines, calculate_buf_edge_line(internal, "top", text.top, text.top_align)) + for _ = 1, size.height - 2 do + table.insert( + lines, + Line({ + Text(left_char), + Text(string.rep(" ", gap_length)), + Text(right_char), + }) + ) + end + table.insert(lines, calculate_buf_edge_line(internal, "bottom", text.bottom, text.bottom_align)) + + return lines +end + +local styles = { + double = to_border_map({ "╔", "═", "╗", "║", "╝", "═", "╚", "║" }), + none = "none", + rounded = to_border_map({ "╭", "─", "╮", "│", "╯", "─", "╰", "│" }), + shadow = "shadow", + single = to_border_map({ "┌", "─", "┐", "│", "┘", "─", "└", "│" }), + solid = to_border_map({ "▛", "▀", "▜", "▐", "▟", "▄", "▙", "▌" }), +} + +---@param internal nui_popup_border_internal +---@return nui_popup_border_internal_size +local function calculate_size_delta(internal) + ---@type nui_popup_border_internal_size + local delta = { + width = 0, + height = 0, + } + + local char = internal.char + if is_type("map", char) then + if char.top ~= "" then + delta.height = delta.height + 1 + end + + if char.bottom ~= "" then + delta.height = delta.height + 1 + end + + if char.left ~= "" then + delta.width = delta.width + 1 + end + + if char.right ~= "" then + delta.width = delta.width + 1 + end + end + + local padding = internal.padding + if padding then + if padding.top then + delta.height = delta.height + padding.top + end + + if padding.bottom then + delta.height = delta.height + padding.bottom + end + + if padding.left then + delta.width = delta.width + padding.left + end + + if padding.right then + delta.width = delta.width + padding.right + end + end + + return delta +end + +---@param border NuiPopupBorder +---@return nui_popup_border_internal_size +local function calculate_size(border) + ---@type nui_popup_border_internal_size + local size = vim.deepcopy(border.popup._.size) + + size.width = size.width + border._.size_delta.width + size.height = size.height + border._.size_delta.height + + return size +end + +---@param border NuiPopupBorder +---@return nui_popup_border_internal_position +local function calculate_position(border) + local position = vim.deepcopy(border.popup._.position) + position.col = position.col - math.floor(border._.size_delta.width / 2 + 0.5) + position.row = position.row - math.floor(border._.size_delta.height / 2 + 0.5) + return position +end + +local function adjust_popup_win_config(border) + local internal = border._ + + if internal.type ~= "complex" then + return + end + + local popup_position = { + row = 0, + col = 0, + } + + local char = internal.char + + if is_type("map", char) then + if char.top ~= "" then + popup_position.row = popup_position.row + 1 + end + + if char.left ~= "" then + popup_position.col = popup_position.col + 1 + end + end + + local padding = internal.padding + + if padding then + if padding.top then + popup_position.row = popup_position.row + padding.top + end + + if padding.left then + popup_position.col = popup_position.col + padding.left + end + end + + local popup = border.popup + + if not has_nvim_0_5_1 then + popup.win_config.row = internal.position.row + popup_position.row + popup.win_config.col = internal.position.col + popup_position.col + return + end + + popup.win_config.relative = "win" + -- anchor to the border window instead + popup.win_config.anchor = "NW" + popup.win_config.win = border.winid + popup.win_config.bufpos = nil + popup.win_config.row = popup_position.row + popup.win_config.col = popup_position.col +end + +--luacheck: push no max line length + +---@alias nui_t_text_align "'left'" | "'center'" | "'right'" +---@alias nui_popup_border_internal_padding { top: number, right: number, bottom: number, left: number } +---@alias nui_popup_border_internal_position { row: number, col: number } +---@alias nui_popup_border_internal_size { width: number, height: number } +---@alias nui_popup_border_internal_text { top?: string|NuiText, top_align?: nui_t_text_align, bottom?: string|NuiText, bottom_align?: nui_t_text_align } +---@alias nui_popup_border_internal { type: "'simple'"|"'complex'", style: table, char: any, padding?: nui_popup_border_internal_padding, position: nui_popup_border_internal_position, size: nui_popup_border_internal_size, size_delta: nui_popup_border_internal_size, text: nui_popup_border_internal_text, lines?: table[], winhighlight?: string } + +--luacheck: pop + +---@class NuiPopupBorder +---@field bufnr integer +---@field private _ nui_popup_border_internal +---@field private popup NuiPopup +---@field win_config nui_popup_win_config +---@field winid number +local Border = Object("NuiPopupBorder") + +---@param popup NuiPopup +function Border:init(popup, options) + self.popup = popup + + self._ = { + type = "simple", + style = defaults(options.style, "none"), + -- @deprecated + highlight = options.highlight, + padding = parse_padding(options.padding), + text = options.text, + } + + local internal = self._ + + if internal.text then + internal.text.top = normalize_border_text(internal.text.top) + internal.text.bottom = normalize_border_text(internal.text.bottom) + end + + local style = internal.style + + if is_type("list", style) then + internal.char = to_border_map(style) + elseif is_type("string", style) then + if not styles[style] then + error("invalid border style name") + end + + internal.char = vim.deepcopy(styles[style]) + else + internal.char = internal.style + end + + local is_borderless = is_type("string", internal.char) + + if is_borderless then + if internal.text then + error("text not supported for style:" .. internal.char) + end + end + + if internal.text or internal.padding then + internal.type = "complex" + end + + internal.winhighlight = calculate_winhighlight(internal, self.popup._.win_options.winhighlight) + + internal.char = normalize_border_char(internal) + + internal.size_delta = calculate_size_delta(internal) + + if internal.type == "simple" then + return self + end + + self.win_config = { + style = "minimal", + border = "none", + focusable = false, + zindex = self.popup.win_config.zindex - 1, + anchor = self.popup.win_config.anchor, + } + + if type(internal.char) == "string" then + self.win_config.border = internal.char + end +end + +function Border:_open_window() + if self.winid or not self.bufnr then + return + end + + self.win_config.noautocmd = true + self.winid = vim.api.nvim_open_win(self.bufnr, false, self.win_config) + self.win_config.noautocmd = nil + assert(self.winid, "failed to create border window") + + if self._.winhighlight then + vim.api.nvim_win_set_option(self.winid, "winhighlight", self._.winhighlight) + end + + adjust_popup_win_config(self) + + vim.api.nvim_command("redraw") +end + +function Border:_close_window() + if not self.winid then + return + end + + if vim.api.nvim_win_is_valid(self.winid) then + vim.api.nvim_win_close(self.winid, true) + end + + self.winid = nil +end + +function Border:mount() + local popup = self.popup + + if not popup._.loading or popup._.mounted then + return + end + + local internal = self._ + + if internal.type == "simple" then + return + end + + self.bufnr = vim.api.nvim_create_buf(false, true) + assert(self.bufnr, "failed to create border buffer") + + if internal.lines then + _.render_lines(internal.lines, self.bufnr, popup.ns_id, 1, #internal.lines) + end + + self:_open_window() +end + +function Border:unmount() + local popup = self.popup + + if not popup._.loading or not popup._.mounted then + return + end + + local internal = self._ + + if internal.type == "simple" then + return + end + + if self.bufnr then + if vim.api.nvim_buf_is_valid(self.bufnr) then + u.clear_namespace(self.bufnr, self.popup.ns_id) + vim.api.nvim_buf_delete(self.bufnr, { force = true }) + end + self.bufnr = nil + end + + self:_close_window() +end + +function Border:_relayout() + local internal = self._ + + if internal.type ~= "complex" then + return + end + + local position = self.popup._.position + self.win_config.relative = position.relative + self.win_config.win = position.relative == "win" and position.win or nil + self.win_config.bufpos = position.bufpos + + internal.size = calculate_size(self) + self.win_config.width = internal.size.width + self.win_config.height = internal.size.height + + internal.position = calculate_position(self) + self.win_config.row = internal.position.row + self.win_config.col = internal.position.col + + internal.lines = calculate_buf_lines(internal) + + if self.winid then + vim.api.nvim_win_set_config(self.winid, self.win_config) + end + + if self.bufnr then + if internal.lines then + _.render_lines(internal.lines, self.bufnr, self.popup.ns_id, 1, #internal.lines) + end + end + + adjust_popup_win_config(self) + + vim.api.nvim_command("redraw") +end + +---@param edge "'top'" | "'bottom'" +---@param text? nil | string | table # string or NuiText +---@param align? nil | "'left'" | "'center'" | "'right'" +function Border:set_text(edge, text, align) + local internal = self._ + + if not internal.lines or not internal.text then + return + end + + internal.text[edge] = normalize_border_text(text) + internal.text[edge .. "_align"] = defaults(align, internal.text[edge .. "_align"]) + + local line = calculate_buf_edge_line(internal, edge, internal.text[edge], internal.text[edge .. "_align"]) + + local linenr = edge == "top" and 1 or #internal.lines + + internal.lines[linenr] = line + line:render(self.bufnr, self.popup.ns_id, linenr) +end + +---@param highlight string highlight group +function Border:set_highlight(highlight) + local internal = self._ + + local winhighlight_data = _.parse_winhighlight(self.popup._.win_options.winhighlight) + winhighlight_data["FloatBorder"] = highlight + self.popup._.win_options.winhighlight = _.serialize_winhighlight(winhighlight_data) + if self.popup.winid then + vim.api.nvim_win_set_option(self.popup.winid, "winhighlight", self.popup._.win_options.winhighlight) + end + + internal.winhighlight = calculate_winhighlight(internal, self.popup._.win_options.winhighlight) + if self.winid then + vim.api.nvim_win_set_option(self.winid, "winhighlight", internal.winhighlight) + end +end + +function Border:get() + local internal = self._ + + if internal.type ~= "simple" then + return nil + end + + if is_type("string", internal.char) then + return internal.char + end + + if is_type("map", internal.char) then + return to_border_list(internal.char) + end +end + +---@alias NuiPopupBorder.constructor fun(popup: NuiPopup, options: table): NuiPopupBorder +---@type NuiPopupBorder|NuiPopupBorder.constructor +local NuiPopupBorder = Border + +return NuiPopupBorder diff --git a/bundle/nui.nvim/lua/nui/popup/init.lua b/bundle/nui.nvim/lua/nui/popup/init.lua new file mode 100644 index 000000000..6c975d25f --- /dev/null +++ b/bundle/nui.nvim/lua/nui/popup/init.lua @@ -0,0 +1,395 @@ +local Border = require("nui.popup.border") +local Object = require("nui.object") +local buf_storage = require("nui.utils.buf_storage") +local autocmd = require("nui.utils.autocmd") +local keymap = require("nui.utils.keymap") + +local utils = require("nui.utils") +local _ = utils._ +local defaults = utils.defaults +local is_type = utils.is_type + +local layout_utils = require("nui.layout.utils") +local u = { + clear_namespace = _.clear_namespace, + get_next_id = _.get_next_id, + size = layout_utils.size, + position = layout_utils.position, + update_layout_config = layout_utils.update_layout_config, +} + +-- luacov: disable +-- @deprecated +---@param opacity number +---@deprecated +local function calculate_winblend(opacity) + assert(0 <= opacity, "opacity must be equal or greater than 0") + assert(opacity <= 1, "opacity must be equal or lesser than 0") + return 100 - (opacity * 100) +end +-- luacov: enable + +local function merge_default_options(options) + options.relative = defaults(options.relative, "win") + + options.enter = defaults(options.enter, false) + options.zindex = defaults(options.zindex, 50) + + options.buf_options = defaults(options.buf_options, {}) + options.win_options = defaults(options.win_options, {}) + + options.border = defaults(options.border, "none") + + return options +end + +local function normalize_options(options) + options = _.normalize_layout_options(options) + + if is_type("string", options.border) then + options.border = { + style = options.border, + } + end + + return options +end + +--luacheck: push no max line length + +---@alias nui_popup_internal_position { relative: "'cursor'"|"'editor'"|"'win'", win: number, bufpos?: number[], row: number, col: number } +---@alias nui_popup_internal_size { height: number, width: number } +---@alias nui_popup_win_config { focusable: boolean, style: "'minimal'", zindex: number, relative: "'cursor'"|"'editor'"|"'win'", win?: number, bufpos?: number[], row: number, col: number, width: number, height: number, border?: table, anchor?: "NW"|"NE"|"SW"|"SE" } +---@alias nui_popup_internal { layout: nui_layout_config, layout_ready: boolean, loading: boolean, mounted: boolean, position: nui_popup_internal_position, size: nui_popup_internal_size, win_enter: boolean, unmanaged_bufnr?: boolean, buf_options: table, win_options: table, win_config: nui_popup_win_config } + +--luacheck: pop + +---@class NuiPopup +---@field border NuiPopupBorder +---@field bufnr integer +---@field ns_id integer +---@field private _ nui_popup_internal +---@field win_config nui_popup_win_config +---@field winid number +local Popup = Object("NuiPopup") + +function Popup:init(options) + local id = u.get_next_id() + + options = merge_default_options(options) + options = normalize_options(options) + + self._ = { + id = id, + buf_options = options.buf_options, + layout = {}, + layout_ready = false, + loading = false, + mounted = false, + win_enter = options.enter, + win_options = options.win_options, + win_config = { + focusable = options.focusable, + style = "minimal", + anchor = options.anchor, + zindex = options.zindex, + }, + augroup = { + hide = string.format("%s_hide", id), + unmount = string.format("%s_unmount", id), + }, + } + + self.win_config = self._.win_config + + self.ns_id = _.normalize_namespace_id(options.ns_id) + + if options.bufnr then + self.bufnr = options.bufnr + self._.unmanaged_bufnr = true + else + self:_buf_create() + end + + -- luacov: disable + -- @deprecated + if not self._.win_options.winblend and is_type("number", options.opacity) then + self._.win_options.winblend = calculate_winblend(options.opacity) + end + + -- @deprecated + if not self._.win_options.winhighlight and not is_type("nil", options.highlight) then + self._.win_options.winhighlight = options.highlight + end + -- luacov: enable + + self.border = Border(self, options.border) + self.win_config.border = self.border:get() + + if options.position and options.size then + self:update_layout(options) + end +end + +function Popup:_open_window() + if self.winid or not self.bufnr then + return + end + + self.win_config.noautocmd = true + self.winid = vim.api.nvim_open_win(self.bufnr, self._.win_enter, self.win_config) + self.win_config.noautocmd = nil + + vim.api.nvim_win_call(self.winid, function() + autocmd.exec("BufWinEnter", { + buffer = self.bufnr, + modeline = false, + }) + end) + + assert(self.winid, "failed to create popup window") + + _.set_win_options(self.winid, self._.win_options) +end + +function Popup:_close_window() + if not self.winid then + return + end + + if vim.api.nvim_win_is_valid(self.winid) then + vim.api.nvim_win_close(self.winid, true) + end + + self.winid = nil +end + +function Popup:_buf_create() + if not self.bufnr then + self.bufnr = vim.api.nvim_create_buf(false, true) + assert(self.bufnr, "failed to create buffer") + end +end + +function Popup:mount() + if not self._.layout_ready then + return error("layout is not ready") + end + + if self._.loading or self._.mounted then + return + end + + self._.loading = true + + autocmd.create_group(self._.augroup.hide, { clear = true }) + autocmd.create_group(self._.augroup.unmount, { clear = true }) + autocmd.create("QuitPre", { + group = self._.augroup.unmount, + buffer = self.bufnr, + callback = vim.schedule_wrap(function() + self:unmount() + end), + }, self.bufnr) + autocmd.create("BufWinEnter", { + group = self._.augroup.unmount, + buffer = self.bufnr, + callback = function() + -- When two popup using the same buffer and both of them + -- are hiddden, calling `:show` for one of them fires + -- `BufWinEnter` for both of them. And in that scenario + -- one of them will not have `self.winid`. + if self.winid then + -- @todo skip registering `WinClosed` multiple times + -- for the same popup + autocmd.create("WinClosed", { + group = self._.augroup.hide, + pattern = tostring(self.winid), + callback = function() + self:hide() + end, + }, self.bufnr) + end + end, + }, self.bufnr) + + self.border:mount() + + self:_buf_create() + + _.set_buf_options(self.bufnr, self._.buf_options) + + self:_open_window() + + self._.loading = false + self._.mounted = true +end + +function Popup:hide() + if self._.loading or not self._.mounted then + return + end + + self._.loading = true + + pcall(autocmd.delete_group, self._.augroup.hide) + + self.border:_close_window() + + self:_close_window() + + self._.loading = false +end + +function Popup:show() + if self._.loading or not self._.mounted then + return + end + + self._.loading = true + + autocmd.create_group(self._.augroup.hide, { clear = true }) + + self.border:_open_window() + + self:_open_window() + + self._.loading = false +end + +function Popup:_buf_destory() + if not self.bufnr then + return + end + + if vim.api.nvim_buf_is_valid(self.bufnr) then + u.clear_namespace(self.bufnr, self.ns_id) + if not self._.unmanaged_bufnr then + vim.api.nvim_buf_delete(self.bufnr, { force = true }) + end + end + + buf_storage.cleanup(self.bufnr) + + if not self._.unmanaged_bufnr then + self.bufnr = nil + end +end + +function Popup:unmount() + if self._.loading or not self._.mounted then + return + end + + self._.loading = true + + pcall(autocmd.delete_group, self._.augroup.hide) + pcall(autocmd.delete_group, self._.augroup.unmount) + + self.border:unmount() + + self:_buf_destory() + + self:_close_window() + + self._.loading = false + self._.mounted = false +end + +-- set keymap for this popup window +---@param mode string check `:h :map-modes` +---@param key string|string[] key for the mapping +---@param handler string | fun(): nil handler for the mapping +---@param opts table<"'expr'"|"'noremap'"|"'nowait'"|"'remap'"|"'script'"|"'silent'"|"'unique'", boolean> +---@return nil +function Popup:map(mode, key, handler, opts, force) + if not self.bufnr then + error("popup buffer not found.") + end + + return keymap.set(self.bufnr, mode, key, handler, opts, force) +end + +---@param mode string check `:h :map-modes` +---@param key string|string[] key for the mapping +---@return nil +function Popup:unmap(mode, key, force) + if not self.bufnr then + error("popup buffer not found.") + end + + return keymap._del(self.bufnr, mode, key, force) +end + +---@param event string | string[] +---@param handler string | function +---@param options nil | table<"'once'" | "'nested'", boolean> +function Popup:on(event, handler, options) + if not self.bufnr then + error("popup buffer not found.") + end + + autocmd.buf.define(self.bufnr, event, handler, options) +end + +---@param event nil | string | string[] +function Popup:off(event) + if not self.bufnr then + error("popup buffer not found.") + end + + autocmd.buf.remove(self.bufnr, nil, event) +end + +-- luacov: disable +-- @deprecated +-- Use `popup:update_layout`. +---@deprecated +function Popup:set_layout(config) + return self:update_layout(config) +end +-- luacov: enable + +---@param config? nui_layout_config +function Popup:update_layout(config) + config = config or {} + + u.update_layout_config(self._, config) + + self.border:_relayout() + + self._.layout_ready = true + + if self.winid then + -- upstream issue: https://github.com/neovim/neovim/issues/20370 + local win_config_style = self.win_config.style + ---@diagnostic disable-next-line: assign-type-mismatch + self.win_config.style = "" + vim.api.nvim_win_set_config(self.winid, self.win_config) + self.win_config.style = win_config_style + end +end + +-- luacov: disable +-- @deprecated +-- Use `popup:update_layout`. +---@deprecated +function Popup:set_size(size) + self:update_layout({ size = size }) +end +-- luacov: enable + +-- luacov: disable +-- @deprecated +-- Use `popup:update_layout`. +---@deprecated +function Popup:set_position(position, relative) + self:update_layout({ position = position, relative = relative }) +end +-- luacov: enable + +---@alias NuiPopup.constructor fun(options: table): NuiPopup +---@type NuiPopup|NuiPopup.constructor +local NuiPopup = Popup + +return NuiPopup diff --git a/bundle/nui.nvim/lua/nui/split/README.md b/bundle/nui.nvim/lua/nui/split/README.md new file mode 100644 index 000000000..d25f9e692 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/split/README.md @@ -0,0 +1,91 @@ +# Split + +Split is can be used to split your current window or editor. + +```lua +local Split = require("nui.split") + +local split = Split({ + relative = "editor", + position = "bottom", + size = "20%", +}) +``` + +You can manipulate the assocciated buffer and window using the +`split.bufnr` and `split.winid` properties. + +## Options + +### `ns_id` + +**Type:** `number` or `string` + +Namespace id (`number`) or name (`string`). + +### `relative` + +**Type:** `string` or `table` + +This option affects how `size` is calculated. + +**Examples** + +Split current editor screen: + +```lua +relative = "editor" +``` + +Split current window (_default_): + +```lua +relative = "win" +``` + +Split window with specific id: + +```lua +relative = { + type = "win", + winid = 42, +} +``` + +### `position` + +`position` can be one of: `"top"`, `"right"`, `"bottom"` or `"left"`. + +### `size` + +`size` can be `number` or `percentage string`. + +For `percentage string`, size is calculated according to the option `relative`. + +### `enter` + +**Type:** `boolean` + +If `false`, the split is not entered immediately after mount. + +**Examples** + +```lua +enter = false +``` + +### `buf_options` + +Table containing buffer options to set for this split. + +### `win_options` + +Table containing window options to set for this split. + +## Methods + +[Methods from `nui.popup`](/lua/nui/popup#methods) are also available for `nui.split`. + +## Wiki Page + +You can find additional documentation/examples/guides/tips-n-tricks in [nui.split wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.split). diff --git a/bundle/nui.nvim/lua/nui/split/init.lua b/bundle/nui.nvim/lua/nui/split/init.lua new file mode 100644 index 000000000..0905a80c1 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/split/init.lua @@ -0,0 +1,334 @@ +local Object = require("nui.object") +local buf_storage = require("nui.utils.buf_storage") +local autocmd = require("nui.utils.autocmd") +local keymap = require("nui.utils.keymap") +local utils = require("nui.utils") +local split_utils = require("nui.split.utils") + +local u = { + clear_namespace = utils._.clear_namespace, + get_next_id = utils._.get_next_id, + normalize_namespace_id = utils._.normalize_namespace_id, + split = split_utils, +} + +local split_direction_command_map = { + editor = { + top = "topleft", + right = "vertical botright", + bottom = "botright", + left = "vertical topleft", + }, + win = { + top = "aboveleft", + right = "vertical rightbelow", + bottom = "belowright", + left = "vertical leftabove", + }, +} + +local function move_split_window(winid, win_config) + if win_config.relative == "editor" then + vim.api.nvim_win_call(winid, function() + vim.cmd("wincmd " .. ({ top = "K", right = "L", bottom = "J", left = "H" })[win_config.position]) + end) + elseif win_config.relative == "win" then + local move_options = { + vertical = win_config.position == "left" or win_config.position == "right", + rightbelow = win_config.position == "bottom" or win_config.position == "right", + } + + vim.cmd( + string.format( + "noautocmd call win_splitmove(%s, %s, #{ vertical: %s, rightbelow: %s })", + winid, + win_config.win, + move_options.vertical and 1 or 0, + move_options.rightbelow and 1 or 0 + ) + ) + end +end + +local function set_win_config(winid, win_config) + if win_config.pending_changes.position then + move_split_window(winid, win_config) + end + + if win_config.pending_changes.size then + if win_config.width then + vim.api.nvim_win_set_width(winid, win_config.width) + elseif win_config.height then + vim.api.nvim_win_set_height(winid, win_config.height) + end + end + + win_config.pending_changes = {} +end + +--luacheck: push no max line length + +---@alias nui_split_internal_position "'top'"|"'right'"|"'bottom'"|"'left'" +---@alias nui_split_internal_relative { type: "'editor'"|"'win'", win: number } +---@alias nui_split_internal_size { width?: number, height?: number } +---@alias nui_split_internal { loading: boolean, mounted: boolean, buf_options: table, win_options: table, position: nui_split_internal_position, relative: nui_split_internal_relative, size: nui_split_internal_size } + +--luacheck: pop + +---@class NuiSplit +---@field private _ nui_split_internal +---@field bufnr integer +---@field ns_id integer +---@field winid number +local Split = Object("NuiSplit") + +---@param options table +function Split:init(options) + local id = u.get_next_id() + + options = u.split.merge_default_options(options) + options = u.split.normalize_options(options) + + self._ = { + id = id, + enter = options.enter, + buf_options = options.buf_options, + loading = false, + mounted = false, + layout = {}, + position = options.position, + size = {}, + win_options = options.win_options, + win_config = { + pending_changes = {}, + }, + augroup = { + hide = string.format("%s_hide", id), + unmount = string.format("%s_unmount", id), + }, + } + + self.ns_id = u.normalize_namespace_id(options.ns_id) + + self:_buf_create() + + self:update_layout(options) +end + +function Split:update_layout(config) + config = config or {} + + u.split.update_layout_config(self._, config) + + if self.winid then + set_win_config(self.winid, self._.win_config) + end +end + +function Split:_open_window() + if self.winid or not self.bufnr then + return + end + + self.winid = vim.api.nvim_win_call(self._.relative.win, function() + vim.api.nvim_command( + string.format( + "silent noswapfile %s %ssplit", + split_direction_command_map[self._.relative.type][self._.position], + self._.size.width or self._.size.height or "" + ) + ) + + return vim.api.nvim_get_current_win() + end) + + vim.api.nvim_win_set_buf(self.winid, self.bufnr) + + if self._.enter then + vim.api.nvim_set_current_win(self.winid) + end + + self._.win_config.pending_changes = { size = true } + set_win_config(self.winid, self._.win_config) + + utils._.set_win_options(self.winid, self._.win_options) +end + +function Split:_close_window() + if not self.winid then + return + end + + if vim.api.nvim_win_is_valid(self.winid) and not self._.pending_quit then + vim.api.nvim_win_close(self.winid, true) + end + + self.winid = nil +end + +function Split:_buf_create() + if not self.bufnr then + self.bufnr = vim.api.nvim_create_buf(false, true) + assert(self.bufnr, "failed to create buffer") + end +end + +function Split:mount() + if self._.loading or self._.mounted then + return + end + + self._.loading = true + + autocmd.create_group(self._.augroup.hide, { clear = true }) + autocmd.create_group(self._.augroup.unmount, { clear = true }) + autocmd.create("QuitPre", { + group = self._.augroup.unmount, + buffer = self.bufnr, + callback = function() + self._.pending_quit = true + vim.schedule(function() + self:unmount() + self._.pending_quit = nil + end) + end, + }, self.bufnr) + autocmd.create("BufWinEnter", { + group = self._.augroup.unmount, + buffer = self.bufnr, + callback = function() + autocmd.create("WinClosed", { + group = self._.augroup.hide, + pattern = tostring(self.winid), + callback = function() + self:hide() + end, + }, self.bufnr) + end, + }, self.bufnr) + + self:_buf_create() + + utils._.set_buf_options(self.bufnr, self._.buf_options) + + self:_open_window() + + self._.loading = false + self._.mounted = true +end + +function Split:hide() + if self._.loading or not self._.mounted then + return + end + + self._.loading = true + + pcall(autocmd.delete_group, self._.augroup.hide) + + self:_close_window() + + self._.loading = false +end + +function Split:show() + if self._.loading or not self._.mounted then + return + end + + self._.loading = true + + autocmd.create_group(self._.augroup.hide, { clear = true }) + + self:_open_window() + + self._.loading = false +end + +function Split:_buf_destroy() + if not self.bufnr then + return + end + + if vim.api.nvim_buf_is_valid(self.bufnr) then + u.clear_namespace(self.bufnr, self.ns_id) + + if not self._.pending_quit then + vim.api.nvim_buf_delete(self.bufnr, { force = true }) + end + end + + buf_storage.cleanup(self.bufnr) + + self.bufnr = nil +end + +function Split:unmount() + if self._.loading or not self._.mounted then + return + end + + self._.loading = true + + pcall(autocmd.delete_group, self._.augroup.hide) + pcall(autocmd.delete_group, self._.augroup.unmount) + + self:_buf_destroy() + + self:_close_window() + + self._.loading = false + self._.mounted = false +end + +-- set keymap for this split +-- `force` is not `true` returns `false`, otherwise returns `true` +---@param mode string check `:h :map-modes` +---@param key string|string[] key for the mapping +---@param handler string | fun(): nil handler for the mapping +---@param opts table<"'expr'"|"'noremap'"|"'nowait'"|"'remap'"|"'script'"|"'silent'"|"'unique'", boolean> +---@return nil +function Split:map(mode, key, handler, opts, force) + if not self.bufnr then + error("split buffer not found.") + end + + return keymap.set(self.bufnr, mode, key, handler, opts, force) +end + +---@param mode string check `:h :map-modes` +---@param key string|string[] key for the mapping +---@return nil +function Split:unmap(mode, key) + if not self.bufnr then + error("split buffer not found.") + end + + return keymap._del(self.bufnr, mode, key) +end + +---@param event string | string[] +---@param handler string | function +---@param options nil | table<"'once'" | "'nested'", boolean> +function Split:on(event, handler, options) + if not self.bufnr then + error("split buffer not found.") + end + + autocmd.buf.define(self.bufnr, event, handler, options) +end + +---@param event nil | string | string[] +function Split:off(event) + if not self.bufnr then + error("split buffer not found.") + end + + autocmd.buf.remove(self.bufnr, nil, event) +end + +---@alias NuiSplit.constructor fun(options: table): NuiSplit +---@type NuiSplit|NuiSplit.constructor +local NuiSplit = Split + +return NuiSplit diff --git a/bundle/nui.nvim/lua/nui/split/utils.lua b/bundle/nui.nvim/lua/nui/split/utils.lua new file mode 100644 index 000000000..1f255234f --- /dev/null +++ b/bundle/nui.nvim/lua/nui/split/utils.lua @@ -0,0 +1,179 @@ +local utils = require("nui.utils") +local layout_utils = require("nui.layout.utils") + +local u = { + defaults = utils.defaults, + get_editor_size = utils.get_editor_size, + get_window_size = utils.get_window_size, + is_type = utils.is_type, + normalize_dimension = utils._.normalize_dimension, + size = layout_utils.size, +} + +local mod = {} + +---@param size number|string|nui_layout_option_size +---@param position nui_split_internal_position +---@return number|string size +local function to_split_size(size, position) + if not u.is_type("table", size) then + ---@cast size number|string + return size + end + + if position == "left" or position == "right" then + return size.width + end + + return size.height +end + +---@param options table +---@return table options +function mod.merge_default_options(options) + options.relative = u.defaults(options.relative, "win") + options.position = u.defaults(options.position, vim.go.splitbelow and "bottom" or "top") + + options.enter = u.defaults(options.enter, true) + + options.buf_options = u.defaults(options.buf_options, {}) + options.win_options = vim.tbl_extend("force", { + winfixwidth = true, + winfixheight = true, + }, u.defaults(options.win_options, {})) + + return options +end + +---@param options table +---@return table options +function mod.normalize_layout_options(options) + if utils.is_type("string", options.relative) then + options.relative = { + type = options.relative, + } + end + + return options +end + +---@param options table +---@return table options +function mod.normalize_options(options) + options = mod.normalize_layout_options(options) + + return options +end + +local function parse_relative(relative, fallback_winid) + local winid = u.defaults(relative.winid, fallback_winid) + + return { + type = relative.type, + win = winid, + } +end + +---@param relative nui_split_internal_relative +local function get_container_info(relative) + if relative.type == "editor" then + local size = u.get_editor_size() + + -- best effort adjustments + size.height = size.height - vim.api.nvim_get_option("cmdheight") + if vim.api.nvim_get_option("laststatus") >= 2 then + size.height = size.height - 1 + end + if vim.api.nvim_get_option("showtabline") == 2 then + size.height = size.height - 1 + end + + return { + size = size, + type = "editor", + } + end + + if relative.type == "win" then + return { + size = u.get_window_size(relative.win), + type = "window", + } + end +end + +---@param position nui_split_internal_position +---@param size number|string +---@param container_size { width: number, height: number } +---@return { width?: number, height?: number } +function mod.calculate_window_size(position, size, container_size) + if not size then + return {} + end + + if position == "left" or position == "right" then + return { + width = u.normalize_dimension(size, container_size.width), + } + end + + return { + height = u.normalize_dimension(size, container_size.height), + } +end + +function mod.update_layout_config(component_internal, config) + local internal = component_internal + + local options = mod.normalize_layout_options({ + relative = config.relative, + position = config.position, + size = config.size, + }) + + if internal.relative and internal.relative.win and not vim.api.nvim_win_is_valid(internal.relative.win) then + internal.relative.win = vim.api.nvim_get_current_win() + + internal.win_config.win = internal.relative.win + + internal.win_config.pending_changes.relative = true + end + + if options.relative then + local fallback_winid = internal.relative and internal.relative.win or vim.api.nvim_get_current_win() + internal.relative = parse_relative(options.relative, fallback_winid) + + local prev_relative = internal.win_config.relative + local prev_win = internal.win_config.win + + internal.win_config.relative = internal.relative.type + internal.win_config.win = internal.relative.type == "win" and internal.relative.win or nil + + internal.win_config.pending_changes.relative = internal.win_config.relative ~= prev_relative + or internal.win_config.win ~= prev_win + end + + if options.position or internal.win_config.pending_changes.relative then + local prev_position = internal.win_config.position + + internal.position = options.position or internal.position + + internal.win_config.position = internal.position + + internal.win_config.pending_changes.position = internal.win_config.position ~= prev_position + end + + if options.size or internal.win_config.pending_changes.position or internal.win_config.pending_changes.relative then + internal.layout.size = to_split_size(options.size or internal.layout.size, internal.position) + + internal.container_info = get_container_info(internal.relative) + internal.size = mod.calculate_window_size(internal.position, internal.layout.size, internal.container_info.size) + + internal.win_config.width = internal.size.width + internal.win_config.height = internal.size.height + + internal.win_config.pending_changes.size = true + end +end + +return mod diff --git a/bundle/nui.nvim/lua/nui/text/README.md b/bundle/nui.nvim/lua/nui/text/README.md new file mode 100644 index 000000000..5dc7802c3 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/text/README.md @@ -0,0 +1,141 @@ +# NuiText + +NuiText is an abstraction layer on top of the following native functions: + +- `vim.api.nvim_buf_set_text` (check `:h nvim_buf_set_text()`) +- `vim.api.nvim_buf_set_extmark` (check `:h nvim_buf_set_extmark()`) + +It helps you set text and add highlight for it on the buffer. + +_Signature:_ `NuiText(content, extmark?)` + +**Examples** + +```lua +local NuiText = require("nui.text") + +local text = NuiText("Something Went Wrong!", "Error") + +local bufnr, ns_id, linenr_start, byte_start = 0, -1, 1, 0 + +text:render(bufnr, ns_id, linenr_start, byte_start) +``` + +## Parameters + +### `content` + +**Type:** `string` or `table` + +Text content or `NuiText` object. + +If `NuiText` object is passed, a copy of it is created. + +### `extmark` + +**Type:** `string` or `table` + +Highlight group name or extmark options. + +If a `string` is passed, it is used as the highlight group name. + +If a `table` is passed it is used as extmark data. It can have the +following keys: + +| Key | Description | +| ------------ | -------------------- | +| `"hl_group"` | highlight group name | + +For more, check `:help nvim_buf_set_extmark()`. + +## Methods + +### `text:set` + +_Signature:_ `text:set(content, extmark?)` + +Sets the text content and highlight information. + +**Parameters** + +| Name | Type | Description | +| --------- | ------------------- | --------------------------------------- | +| `content` | `string` | text content | +| `extmark` | `string` or `table` | highlight group name or extmark options | + +This `extmark` parameter is exactly the same as `NuiText`'s `extmark` parameter. + +### `text:content` + +_Signature:_ `text:content()` + +Returns the text content. + +### `text:length` + +_Signature:_ `text:length()` + +Returns the byte length of the text. + +### `text:width` + +_Signature:_ `text:width()` + +Returns the character length of the text. + +### `text:highlight` + +_Signature:_ `text:highlight(bufnr, ns_id, linenr, byte_start)` + +Applies highlight for the text. + +**Parameters** + +| Name | Type | Description | +| ------------ | -------- | -------------------------------------------------- | +| `bufnr` | `number` | buffer number | +| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) | +| `linenr` | `number` | line number (1-indexed) | +| `byte_start` | `number` | start position of the text on the line (0-indexed) | + +### `text:render` + +_Signature:_ `text:render(bufnr, ns_id, linenr_start, byte_start, linenr_end?, byte_end?)` + +Sets the text on buffer and applies highlight. + +**Parameters** + +| Name | Type | Description | +| -------------- | -------- | -------------------------------------------------- | +| `bufnr` | `number` | buffer number | +| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) | +| `linenr_start` | `number` | start line number (1-indexed) | +| `byte_start` | `number` | start position of the text on the line (0-indexed) | +| `linenr_end` | `number` | end line number (1-indexed) | +| `byte_end` | `number` | end position of the text on the line (0-indexed) | + +### `text:render_char` + +_Signature:_ `text:render_char(bufnr, ns_id, linenr_start, char_start, linenr_end?, char_end?)` + +Sets the text on buffer and applies highlight. + +This does the thing as `text:render` method, but you can use character count +instead of byte count. It will convert multibyte character count to appropriate +byte count for you. + +**Parameters** + +| Name | Type | Description | +| -------------- | -------- | -------------------------------------------------- | +| `bufnr` | `number` | buffer number | +| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) | +| `linenr_start` | `number` | start line number (1-indexed) | +| `char_start` | `number` | start position of the text on the line (0-indexed) | +| `linenr_end` | `number` | end line number (1-indexed) | +| `char_end` | `number` | end position of the text on the line (0-indexed) | + +## Wiki Page + +You can find additional documentation/examples/guides/tips-n-tricks in [nui.text wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.text). diff --git a/bundle/nui.nvim/lua/nui/text/init.lua b/bundle/nui.nvim/lua/nui/text/init.lua new file mode 100644 index 000000000..adfeb3b7e --- /dev/null +++ b/bundle/nui.nvim/lua/nui/text/init.lua @@ -0,0 +1,114 @@ +local Object = require("nui.object") +local _ = require("nui.utils")._ +local is_type = require("nui.utils").is_type + +---@class NuiText +---@field protected extmark? table|{ id?: number, hl_group: string } +local Text = Object("NuiText") + +---@param content string|NuiText text content or NuiText object +---@param extmark? string|table highlight group name or extmark options +function Text:init(content, extmark) + if is_type("table", content) then + -- cloning + self:set(content._content, extmark or content.extmark) + else + self:set(content, extmark) + end +end + +---@param content string text content +---@param extmark? string|table highlight group name or extmark options +---@return NuiText +function Text:set(content, extmark) + if self._content ~= content then + self._content = content + self._length = vim.fn.strlen(content) + self._width = vim.api.nvim_strwidth(content) + end + + if extmark then + -- preserve self.extmark.id + local id = self.extmark and self.extmark.id or nil + self.extmark = is_type("string", extmark) and { hl_group = extmark } or vim.deepcopy(extmark) + self.extmark.id = id + end + + return self +end + +---@return string +function Text:content() + return self._content +end + +---@return number +function Text:length() + return self._length +end + +---@return number +function Text:width() + return self._width +end + +---@param bufnr number buffer number +---@param ns_id number namespace id +---@param linenr number line number (1-indexed) +---@param byte_start number start byte position (0-indexed) +---@return nil +function Text:highlight(bufnr, ns_id, linenr, byte_start) + if not self.extmark then + return + end + + self.extmark.end_col = byte_start + self:length() + + self.extmark.id = vim.api.nvim_buf_set_extmark( + bufnr, + _.ensure_namespace_id(ns_id), + linenr - 1, + byte_start, + self.extmark + ) +end + +---@param bufnr number buffer number +---@param ns_id number namespace id +---@param linenr_start number start line number (1-indexed) +---@param byte_start number start byte position (0-indexed) +---@param linenr_end? number end line number (1-indexed) +---@param byte_end? number end byte position (0-indexed) +---@return nil +function Text:render(bufnr, ns_id, linenr_start, byte_start, linenr_end, byte_end) + local row_start = linenr_start - 1 + local row_end = linenr_end and linenr_end - 1 or row_start + + local col_start = byte_start + local col_end = byte_end or byte_start + self:length() + + local content = self:content() + + vim.api.nvim_buf_set_text(bufnr, row_start, col_start, row_end, col_end, { content }) + + self:highlight(bufnr, ns_id, linenr_start, byte_start) +end + +---@param bufnr number buffer number +---@param ns_id number namespace id +---@param linenr_start number start line number (1-indexed) +---@param char_start number start character position (0-indexed) +---@param linenr_end? number end line number (1-indexed) +---@param char_end? number end character position (0-indexed) +---@return nil +function Text:render_char(bufnr, ns_id, linenr_start, char_start, linenr_end, char_end) + char_end = char_end or char_start + self:width() + local byte_range = _.char_to_byte_range(bufnr, linenr_start, char_start, char_end) + self:render(bufnr, ns_id, linenr_start, byte_range[1], linenr_end, byte_range[2]) +end + +---@alias NuiText.constructor fun(content: string|NuiText, extmark?: string|table): NuiText +---@type NuiText|NuiText.constructor +local NuiText = Text + +return NuiText diff --git a/bundle/nui.nvim/lua/nui/tree/README.md b/bundle/nui.nvim/lua/nui/tree/README.md new file mode 100644 index 000000000..48e73fa84 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/tree/README.md @@ -0,0 +1,308 @@ +# NuiTree + +NuiTree can render tree-like structured content on the buffer. + +**Examples** + +```lua +local NuiTree = require("nui.tree") + +local tree = NuiTree({ + bufnr = bufnr, + nodes = { + NuiTree.Node({ text = "a" }), + NuiTree.Node({ text = "b" }, { + NuiTree.Node({ text = "b-1" }), + NuiTree.Node({ text = { "b-2", "b-3" } }), + }), + }, +}) + +tree:render() +``` + +## Options + +### `bufnr` + +**Type:** `number` + +Id of the buffer where the tree will be rendered. + +--- + +### `ns_id` + +**Type:** `number` or `string` + +Namespace id (`number`) or name (`string`). + +--- + +### `nodes` + +**Type:** `table` + +List of [`NuiTree.Node`](#nuitreenode) objects. + +--- + +### `get_node_id` + +**Type:** `function` + +_Signature:_ `get_node_id(node) -> string` + +If provided, this function is used for generating node's id. + +The return value should be a unique `string`. + +**Example** + +```lua +get_node_id = function(node) + if node.id then + return "-" .. node.id + end + + if node.text then + return string.format("%s-%s-%s", node:get_parent_id() or "", node:get_depth(), node.text) + end + + return "-" .. math.random() +end, +``` + +--- + +### `prepare_node` + +**Type:** `function` + +_Signature:_ `prepare_node(node, parent_node?) -> nil | string | string[] | NuiLine | NuiLine[]` + +If provided, this function is used for preparing each node line. + +The return value should be a `NuiLine` object or `string` or a list containing either of them. + +If return value is `nil`, that node will not be rendered. + +**Example** + +```lua +prepare_node = function(node) + local line = NuiLine() + + line:append(string.rep(" ", node:get_depth() - 1)) + + if node:has_children() then + line:append(node:is_expanded() and " " or " ") + else + line:append(" ") + end + + line:append(node.text) + + return line +end, +``` + +--- + +### `buf_options` + +**Type:** `table` + +Contains all buffer related options (check `:h options | /local to buffer`). + +**Examples** + +```lua +buf_options = { + bufhidden = "hide", + buflisted = false, + buftype = "nofile", + swapfile = false, +}, +``` + +## Methods + +### `tree:get_node` + +_Signature:_ `tree:get_node(node_id_or_linenr?) -> NuiTreeNode | nil, number | nil, number | nil` + +**Parameters** + +| Name | Type | Description | +| ------------------- | ----------------------------- | ------------------------ | +| `node_id_or_linenr` | `number` or `string` or `nil` | node's id or line number | + +If `node_id_or_linenr` is `string`, the node with that _id_ is returned. + +If `node_id_or_linenr` is `number`, the node on that _linenr_ is returned. + +If `node_id` is `nil`, the current node under cursor is returned. + +Returns the `node` if found, and the start and end `linenr` if it is rendered. + +### `tree:get_nodes` + +_Signature:_ `tree:get_node(parent_id?) -> NuiTreeNode[]` + +**Parameters** + +| Name | Type | Description | +| ----------- | ----------------- | ---------------- | +| `parent_id` | `string` or `nil` | parent node's id | + +If `parent_id` is present, child nodes under that parent are returned, +Otherwise root nodes are returned. + +### `tree:add_node` + +_Signature:_ `tree:add_node(node, parent_id?)` + +Adds a node to the tree. + +| Name | Type | Description | +| ----------- | ----------------- | ---------------- | +| `node` | `NuiTree.Node` | node | +| `parent_id` | `string` or `nil` | parent node's id | + +If `parent_id` is present, node is added under that parent, +Otherwise node is added to the tree root. + +### `tree:remove_node` + +_Signature:_ `tree:remove_node(node)` + +Removes a node from the tree. + +Returns the removed node. + +| Name | Type | Description | +| --------- | -------- | ----------- | +| `node_id` | `string` | node's id | + +### `tree:set_nodes` + +_Signature:_ `tree:set_nodes(nodes, parent_id?)` + +Adds a node to the tree. + +| Name | Type | Description | +| ----------- | ----------------- | ---------------- | +| `nodes` | `NuiTree.Node[]` | list of nodes | +| `parent_id` | `string` or `nil` | parent node's id | + +If `parent_id` is present, nodes are set as parent node's children, +otherwise nodes are set at tree root. + +### `tree:render` + +_Signature:_ `tree:render(linenr_start?)` + +Renders the tree on buffer. + +| Name | Type | Description | +| -------------- | ---------------- | ----------------------------- | +| `linenr_start` | `number` / `nil` | start line number (1-indexed) | + +## NuiTree.Node + +`NuiTree.Node` is used to create a node object for `NuiTree`. + +_Signature:_ `NuiTree.Node(data, children)` + +**Examples** + +```lua +local NuiTree = require("nui.tree") + +local node = NuiTree.Node({ text = "b" }, { + NuiTree.Node({ text = "b-1" }), + NuiTree.Node({ text = "b-2" }), +}) +``` + +### Parameters + +#### `data` + +**Type:** `table` + +Data for the node. Can contain anything. The default `get_node_id` +and `prepare_node` functions uses the `id` and `text` keys. + +**Example** + +```lua +{ + id = "/usr/local/bin/lua", + text = "lua" +} +``` + +If you don't want to provide those two values, you should consider +providing your own `get_node_id` and `prepare_node` functions. + +#### `children` + +**Type:** `table` + +List of `NuiTree.Node` objects. + +### Methods + +#### `node:get_id` + +_Signature:_ `node:get_id()` + +Returns node's id. + +#### `node:get_depth` + +_Signature:_ `node:get_depth()` + +Returns node's depth. + +#### `node:get_parent_id` + +_Signature:_ `node:get_parent_id()` + +Returns parent node's id. + +#### `node:has_children` + +_Signature:_ `node:has_children()` + +Checks if node has children. + +#### `node:get_child_ids` + +_Signature:_ `node:get_child_ids() -> string[]` + +Returns ids of child nodes. + +#### `node:is_expanded` + +_Signature:_ `node:is_expanded()` + +Checks if node is expanded. + +#### `node:expand` + +_Signature:_ `node:expand()` + +Expands node. + +#### `node:collapse` + +_Signature:_ `node:collapse()` + +Collapses node. + +## Wiki Page + +You can find additional documentation/examples/guides/tips-n-tricks in [nui.tree wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.tree). diff --git a/bundle/nui.nvim/lua/nui/tree/init.lua b/bundle/nui.nvim/lua/nui/tree/init.lua new file mode 100644 index 000000000..081aaf13b --- /dev/null +++ b/bundle/nui.nvim/lua/nui/tree/init.lua @@ -0,0 +1,484 @@ +local Object = require("nui.object") +local _ = require("nui.utils")._ +local defaults = require("nui.utils").defaults +local is_type = require("nui.utils").is_type +local tree_util = require("nui.tree.util") + +local u = { + clear_namespace = _.clear_namespace, + normalize_namespace_id = _.normalize_namespace_id, +} + +---@param bufnr number +---@param linenr_range { [1]: integer, [2]: integer } +local function clear_buf_lines(bufnr, linenr_range) + local count = linenr_range[2] - linenr_range[1] + 1 + + if count < 1 then + return + end + + local lines = {} + for i = 1, count do + lines[i] = "" + end + + vim.api.nvim_buf_set_lines(bufnr, linenr_range[1] - 1, linenr_range[2], false, lines) +end + +-- returns id of the first window that contains the buffer +---@param bufnr number +---@return number winid +local function get_winid(bufnr) + return vim.fn.win_findbuf(bufnr)[1] +end + +---@param nodes NuiTreeNode[] +---@param parent_node? NuiTreeNode +---@param get_node_id nui_tree_get_node_id +---@return { by_id: table, root_ids: string[] } +local function initialize_nodes(nodes, parent_node, get_node_id) + local start_depth = parent_node and parent_node:get_depth() + 1 or 1 + + ---@type table + local by_id = {} + ---@type string[] + local root_ids = {} + + ---@param node NuiTreeNode + ---@param depth number + local function initialize(node, depth) + node._depth = depth + node._id = get_node_id(node) + node._initialized = true + + local node_id = node:get_id() + + if by_id[node_id] then + error("duplicate node id" .. node_id) + end + + by_id[node_id] = node + + if depth == start_depth then + table.insert(root_ids, node_id) + end + + if not node.__children or #node.__children == 0 then + return + end + + if not node._child_ids then + node._child_ids = {} + end + + for _, child_node in ipairs(node.__children) do + child_node._parent_id = node_id + initialize(child_node, depth + 1) + table.insert(node._child_ids, child_node:get_id()) + end + + node.__children = nil + end + + for _, node in ipairs(nodes) do + node._parent_id = parent_node and parent_node:get_id() or nil + initialize(node, start_depth) + end + + return { + by_id = by_id, + root_ids = root_ids, + } +end + +---@class NuiTreeNode +local TreeNode = { + super = nil, +} + +---@return string +function TreeNode:get_id() + return self._id +end + +---@return number +function TreeNode:get_depth() + return self._depth +end + +---@return string|nil +function TreeNode:get_parent_id() + return self._parent_id +end + +---@return boolean +function TreeNode:has_children() + return #(self._child_ids or self.__children or {}) > 0 +end + +---@return string[] +function TreeNode:get_child_ids() + return self._child_ids or {} +end + +---@return boolean +function TreeNode:is_expanded() + return self._is_expanded +end + +---@return boolean is_updated +function TreeNode:expand() + if self:has_children() and not self:is_expanded() then + self._is_expanded = true + return true + end + return false +end + +---@return boolean is_updated +function TreeNode:collapse() + if self:is_expanded() then + self._is_expanded = false + return true + end + return false +end + +--luacheck: push no max line length + +---@alias nui_tree_get_node_id fun(node: NuiTreeNode): string +---@alias nui_tree_prepare_node fun(node: NuiTreeNode, parent_node?: NuiTreeNode): nil | string | string[] | NuiLine | NuiLine[] +---@alias nui_tree_internal { buf_options: table, win_options: table, get_node_id: nui_tree_get_node_id, prepare_node: nui_tree_prepare_node, track_tree_linenr?: boolean } + +--luacheck: pop + +---@class NuiTree +---@field bufnr integer +---@field nodes { by_id: table, root_ids: string[] } +---@field ns_id integer +---@field private _ nui_tree_internal +---@field winid number # @deprecated +local Tree = Object("NuiTree") + +function Tree:init(options) + ---@deprecated + if options.winid then + if not vim.api.nvim_win_is_valid(options.winid) then + error("invalid winid " .. options.winid) + end + + self.winid = options.winid + self.bufnr = vim.api.nvim_win_get_buf(self.winid) + end + + if options.bufnr then + if not vim.api.nvim_buf_is_valid(options.bufnr) then + error("invalid bufnr " .. options.bufnr) + end + + self.bufnr = options.bufnr + self.winid = nil + end + + if not self.bufnr then + error("missing bufnr") + end + + self.ns_id = u.normalize_namespace_id(options.ns_id) + + self._ = { + buf_options = vim.tbl_extend("force", { + bufhidden = "hide", + buflisted = false, + buftype = "nofile", + modifiable = false, + readonly = true, + swapfile = false, + undolevels = 0, + }, defaults(options.buf_options, {})), + ---@deprecated + win_options = vim.tbl_extend("force", { + foldcolumn = "0", + foldmethod = "manual", + wrap = false, + }, defaults(options.win_options, {})), + get_node_id = defaults(options.get_node_id, tree_util.default_get_node_id), + prepare_node = defaults(options.prepare_node, tree_util.default_prepare_node), + track_tree_linenr = nil, + } + + _.set_buf_options(self.bufnr, self._.buf_options) + + ---@deprecated + if self.winid then + _.set_win_options(self.winid, self._.win_options) + end + + self:set_nodes(defaults(options.nodes, {})) +end + +---@generic D : table +---@param data D data table +---@param children NuiTreeNode[] +---@return NuiTreeNode|D +function Tree.Node(data, children) + ---@type NuiTreeNode + local self = { + __children = children, + _initialized = false, + _is_expanded = false, + _child_ids = nil, + _parent_id = nil, + _depth = nil, + _id = nil, + } + + self = setmetatable(vim.tbl_extend("keep", self, data), { + __index = TreeNode, + __name = "NuiTreeNode", + }) + + return self +end + +---@param node_id_or_linenr? string | number +---@return NuiTreeNode|nil node +---@return number|nil linenr +function Tree:get_node(node_id_or_linenr) + if is_type("string", node_id_or_linenr) then + return self.nodes.by_id[node_id_or_linenr], unpack(self._content.linenr_by_node_id[node_id_or_linenr] or {}) + end + + local winid = get_winid(self.bufnr) + local linenr = node_id_or_linenr or vim.api.nvim_win_get_cursor(winid)[1] + local node_id = self._content.node_id_by_linenr[linenr] + return self.nodes.by_id[node_id], unpack(self._content.linenr_by_node_id[node_id] or {}) +end + +---@param parent_id? string parent node's id +---@return NuiTreeNode[] nodes +function Tree:get_nodes(parent_id) + local node_ids = {} + + if parent_id then + local parent_node = self.nodes.by_id[parent_id] + if parent_node then + node_ids = parent_node._child_ids + end + else + node_ids = self.nodes.root_ids + end + + return vim.tbl_map(function(id) + return self.nodes.by_id[id] + end, node_ids or {}) +end + +---@param nodes NuiTreeNode[] +---@param parent_node? NuiTreeNode +function Tree:_add_nodes(nodes, parent_node) + local new_nodes = initialize_nodes(nodes, parent_node, self._.get_node_id) + + self.nodes.by_id = vim.tbl_extend("force", self.nodes.by_id, new_nodes.by_id) + + if parent_node then + if not parent_node._child_ids then + parent_node._child_ids = {} + end + + for _, id in ipairs(new_nodes.root_ids) do + table.insert(parent_node._child_ids, id) + end + else + for _, id in ipairs(new_nodes.root_ids) do + table.insert(self.nodes.root_ids, id) + end + end +end + +---@param nodes NuiTreeNode[] +---@param parent_id? string parent node's id +function Tree:set_nodes(nodes, parent_id) + --luacheck: push no max line length + + ---@type { linenr: {[1]?:integer,[2]?:integer}, lines: string[]|NuiLine[], node_id_by_linenr: table, linenr_by_node_id: table } + self._content = { linenr = {}, lines = {}, node_id_by_linenr = {}, linenr_by_node_id = {} } + + --luacheck: pop + + if not parent_id then + self.nodes = { by_id = {}, root_ids = {} } + self:_add_nodes(nodes) + return + end + + local parent_node = self.nodes.by_id[parent_id] + if not parent_node then + error("invalid parent_id " .. parent_id) + end + + if parent_node._child_ids then + for _, node_id in ipairs(parent_node._child_ids) do + self.nodes.by_id[node_id] = nil + end + + parent_node._child_ids = nil + end + + self:_add_nodes(nodes, parent_node) +end + +---@param node NuiTreeNode +---@param parent_id? string parent node's id +function Tree:add_node(node, parent_id) + local parent_node = self.nodes.by_id[parent_id] + if parent_id and not parent_node then + error("invalid parent_id " .. parent_id) + end + + self:_add_nodes({ node }, parent_node) +end + +local function remove_node(tree, node_id) + local node = tree.nodes.by_id[node_id] + if node:has_children() then + for _, child_id in ipairs(node._child_ids) do + -- We might want to store the nodes and return them with the node itself? + -- We should _really_ not be doing this recursively, but it will work for now + remove_node(tree, child_id) + end + end + tree.nodes.by_id[node_id] = nil + return node +end + +---@param node_id string +---@return NuiTreeNode +function Tree:remove_node(node_id) + local node = remove_node(self, node_id) + local parent_id = node._parent_id + if parent_id then + local parent_node = self.nodes.by_id[parent_id] + parent_node._child_ids = vim.tbl_filter(function(id) + return id ~= node_id + end, parent_node._child_ids) + else + self.nodes.root_ids = vim.tbl_filter(function(id) + return id ~= node_id + end, self.nodes.root_ids) + end + return node +end + +---@param linenr_start number start line number (1-indexed) +function Tree:_prepare_content(linenr_start) + self._content.lines = {} + self._content.node_id_by_linenr = {} + self._content.linenr_by_node_id = {} + + local current_linenr = 1 + + local function prepare(node_id, parent_node) + local node = self.nodes.by_id[node_id] + if not node then + return + end + + local lines = self._.prepare_node(node, parent_node) + + if lines then + if not is_type("table", lines) or lines.content then + lines = { lines } + end + + local linenr = {} + for _, line in ipairs(lines) do + self._content.lines[current_linenr] = line + self._content.node_id_by_linenr[current_linenr + linenr_start - 1] = node:get_id() + linenr[1] = linenr[1] or (current_linenr + linenr_start - 1) + linenr[2] = (current_linenr + linenr_start - 1) + current_linenr = current_linenr + 1 + end + self._content.linenr_by_node_id[node:get_id()] = linenr + end + + if not node:has_children() or not node:is_expanded() then + return + end + + for _, child_node_id in ipairs(node:get_child_ids()) do + prepare(child_node_id, node) + end + end + + for _, node_id in ipairs(self.nodes.root_ids) do + prepare(node_id) + end + + self._content.linenr = { linenr_start, current_linenr - 1 + linenr_start - 1 } +end + +---@param linenr_start? number start line number (1-indexed) +function Tree:render(linenr_start) + if is_type("nil", self._.track_tree_linenr) then + self._.track_tree_linenr = is_type("number", linenr_start) + end + + linenr_start = linenr_start or self._content.linenr[1] or 1 + + local prev_linenr = { self._content.linenr[1], self._content.linenr[2] } + self:_prepare_content(linenr_start) + local next_linenr = { self._content.linenr[1], self._content.linenr[2] } + + _.set_buf_options(self.bufnr, { modifiable = true, readonly = false }) + + local buf_lines = vim.tbl_map(function(line) + if is_type("string", line) then + return line + end + return line:content() + end, self._content.lines) + + if self._.track_tree_linenr then + u.clear_namespace(self.bufnr, self.ns_id, prev_linenr[1], prev_linenr[2]) + + -- if linenr_start was shifted downwards, clear the + -- previously rendered buffer lines above the tree. + clear_buf_lines(self.bufnr, { + math.min(next_linenr[1], prev_linenr[1] or next_linenr[1]), + prev_linenr[1] and next_linenr[1] - 1 or 0, + }) + + -- for initial render, start inserting the tree in a single buffer line. + local content_linenr_range = { + next_linenr[1], + next_linenr[1], + } + -- for subsequent renders, replace the buffer lines from previous tree. + if prev_linenr[1] then + content_linenr_range[2] = prev_linenr[2] < next_linenr[2] and math.min(next_linenr[2], prev_linenr[2]) + or math.max(next_linenr[2], prev_linenr[2]) + end + + vim.api.nvim_buf_set_lines(self.bufnr, content_linenr_range[1] - 1, content_linenr_range[2], false, buf_lines) + else + u.clear_namespace(self.bufnr, self.ns_id) + + vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, buf_lines) + end + + for i, line in ipairs(self._content.lines) do + if not is_type("string", line) then + line:highlight(self.bufnr, self.ns_id, i + linenr_start - 1) + end + end + + _.set_buf_options(self.bufnr, { modifiable = false, readonly = true }) +end + +---@alias NuiTree.constructor fun(options: table): NuiTree +---@type NuiTree|NuiTree.constructor +local NuiTree = Tree + +return NuiTree diff --git a/bundle/nui.nvim/lua/nui/tree/util.lua b/bundle/nui.nvim/lua/nui/tree/util.lua new file mode 100644 index 000000000..8e1bce542 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/tree/util.lua @@ -0,0 +1,70 @@ +local NuiLine = require("nui.line") + +local mod = {} + +---@param node table NuiTreeNode +---@return string node_id id +function mod.default_get_node_id(node) + if node.id then + return "-" .. node.id + end + + if node.text then + local texts = node.text + if type(node.text) ~= "table" or node.text.content then + texts = { node.text } + end + return string.format( + "%s-%s-%s", + node._parent_id or "", + node._depth, + table.concat( + vim.tbl_map(function(text) + if type(text) == "string" then + return text + end + return text:content() + end, texts), + "-" + ) + ) + end + + return "-" .. math.random() +end + +---@param node table NuiTreeNode +---@return table[] lines NuiLine[] +function mod.default_prepare_node(node) + if not node.text then + error("missing node.text") + end + + local texts = node.text + + if type(node.text) ~= "table" or node.text.content then + texts = { node.text } + end + + local lines = {} + + for i, text in ipairs(texts) do + local line = NuiLine() + + line:append(string.rep(" ", node._depth - 1)) + + if i == 1 and node:has_children() then + line:append(node:is_expanded() and " " or " ") + else + line:append(" ") + end + + line:append(text) + + table.insert(lines, line) + end + + return lines +end + +return mod diff --git a/bundle/nui.nvim/lua/nui/utils/autocmd.lua b/bundle/nui.nvim/lua/nui/utils/autocmd.lua new file mode 100644 index 000000000..2a1fc6c1a --- /dev/null +++ b/bundle/nui.nvim/lua/nui/utils/autocmd.lua @@ -0,0 +1,478 @@ +local buf_storage = require("nui.utils.buf_storage") +local is_type = require("nui.utils").is_type +local feature = require("nui.utils")._.feature + +local autocmd = { + event = { + -- after adding a buffer to the buffer list + BufAdd = "BufAdd", + -- deleting a buffer from the buffer list + BufDelete = "BufDelete", + -- after entering a buffer + BufEnter = "BufEnter", + -- after renaming a buffer + BufFilePost = "BufFilePost", + -- before renaming a buffer + BufFilePre = "BufFilePre", + -- just after buffer becomes hidden + BufHidden = "BufHidden", + -- before leaving a buffer + BufLeave = "BufLeave", + -- after the 'modified' state of a buffer changes + BufModifiedSet = "BufModifiedSet", + -- after creating any buffer + BufNew = "BufNew", + -- when creating a buffer for a new file + BufNewFile = "BufNewFile", + -- read buffer using command + BufReadCmd = "BufReadCmd", + -- after reading a buffer + BufReadPost = "BufReadPost", + -- before reading a buffer + BufReadPre = "BufReadPre", + -- just before unloading a buffer + BufUnload = "BufUnload", + -- after showing a buffer in a window + BufWinEnter = "BufWinEnter", + -- just after buffer removed from window + BufWinLeave = "BufWinLeave", + -- just before really deleting a buffer + BufWipeout = "BufWipeout", + -- write buffer using command + BufWriteCmd = "BufWriteCmd", + -- after writing a buffer + BufWritePost = "BufWritePost", + -- before writing a buffer + BufWritePre = "BufWritePre", + -- info was received about channel + ChanInfo = "ChanInfo", + -- channel was opened + ChanOpen = "ChanOpen", + -- command undefined + CmdUndefined = "CmdUndefined", + -- command line was modified + CmdlineChanged = "CmdlineChanged", + -- after entering cmdline mode + CmdlineEnter = "CmdlineEnter", + -- before leaving cmdline mode + CmdlineLeave = "CmdlineLeave", + -- after entering the cmdline window + CmdWinEnter = "CmdwinEnter", + -- before leaving the cmdline window + CmdWinLeave = "CmdwinLeave", + -- after loading a colorscheme + ColorScheme = "ColorScheme", + -- before loading a colorscheme + ColorSchemePre = "ColorSchemePre", + -- after popup menu changed + CompleteChanged = "CompleteChanged", + -- after finishing insert complete + CompleteDone = "CompleteDone", + -- idem, before clearing info + CompleteDonePre = "CompleteDonePre", + -- cursor in same position for a while + CursorHold = "CursorHold", + -- idem, in Insert mode + CursorHoldI = "CursorHoldI", + -- cursor was moved + CursorMoved = "CursorMoved", + -- cursor was moved in Insert mode + CursorMovedI = "CursorMovedI", + -- diffs have been updated + DiffUpdated = "DiffUpdated", + -- directory changed + DirChanged = "DirChanged", + -- after changing the 'encoding' option + EncodingChanged = "EncodingChanged", + -- before exiting + ExitPre = "ExitPre", + -- append to a file using command + FileAppendCmd = "FileAppendCmd", + -- after appending to a file + FileAppendPost = "FileAppendPost", + -- before appending to a file + FileAppendPre = "FileAppendPre", + -- before first change to read-only file + FileChangedRO = "FileChangedRO", + -- after shell command that changed file + FileChangedShell = "FileChangedShell", + -- after (not) reloading changed file + FileChangedShellPost = "FileChangedShellPost", + -- read from a file using command + FileReadCmd = "FileReadCmd", + -- after reading a file + FileReadPost = "FileReadPost", + -- before reading a file + FileReadPre = "FileReadPre", + -- new file type detected (user defined) + FileType = "FileType", + -- write to a file using command + FileWriteCmd = "FileWriteCmd", + -- after writing a file + FileWritePost = "FileWritePost", + -- before writing a file + FileWritePre = "FileWritePre", + -- after reading from a filter + FilterReadPost = "FilterReadPost", + -- before reading from a filter + FilterReadPre = "FilterReadPre", + -- after writing to a filter + FilterWritePost = "FilterWritePost", + -- before writing to a filter + FilterWritePre = "FilterWritePre", + -- got the focus + FocusGained = "FocusGained", + -- lost the focus to another app + FocusLost = "FocusLost", + -- if calling a function which doesn't exist + FuncUndefined = "FuncUndefined", + -- after starting the GUI + GUIEnter = "GUIEnter", + -- after starting the GUI failed + GUIFailed = "GUIFailed", + -- when changing Insert/Replace mode + InsertChange = "InsertChange", + -- before inserting a char + InsertCharPre = "InsertCharPre", + -- when entering Insert mode + InsertEnter = "InsertEnter", + -- just after leaving Insert mode + InsertLeave = "InsertLeave", + -- just before leaving Insert mode + InsertLeavePre = "InsertLeavePre", + -- just before popup menu is displayed + MenuPopup = "MenuPopup", + -- after changing the mode + ModeChanged = "ModeChanged", + -- after setting any option + OptionSet = "OptionSet", + -- after :make, :grep etc. + QuickFixCmdPost = "QuickFixCmdPost", + -- before :make, :grep etc. + QuickFixCmdPre = "QuickFixCmdPre", + -- before :quit + QuitPre = "QuitPre", + -- upon string reception from a remote vim + RemoteReply = "RemoteReply", + -- when the search wraps around the document + SearchWrapped = "SearchWrapped", + -- after loading a session file + SessionLoadPost = "SessionLoadPost", + -- after ":!cmd" + ShellCmdPost = "ShellCmdPost", + -- after ":1,2!cmd", ":w !cmd", ":r !cmd". + ShellFilterPost = "ShellFilterPost", + -- after nvim process received a signal + Signal = "Signal", + -- sourcing a Vim script using command + SourceCmd = "SourceCmd", + -- after sourcing a Vim script + SourcePost = "SourcePost", + -- before sourcing a Vim script + SourcePre = "SourcePre", + -- spell file missing + SpellFileMissing = "SpellFileMissing", + -- after reading from stdin + StdinReadPost = "StdinReadPost", + -- before reading from stdin + StdinReadPre = "StdinReadPre", + -- found existing swap file + SwapExists = "SwapExists", + -- syntax selected + Syntax = "Syntax", + -- a tab has closed + TabClosed = "TabClosed", + -- after entering a tab page + TabEnter = "TabEnter", + -- before leaving a tab page + TabLeave = "TabLeave", + -- when creating a new tab + TabNew = "TabNew", + -- after entering a new tab + TabNewEntered = "TabNewEntered", + -- after changing 'term' + TermChanged = "TermChanged", + -- after the process exits + TermClose = "TermClose", + -- after entering Terminal mode + TermEnter = "TermEnter", + -- after leaving Terminal mode + TermLeave = "TermLeave", + -- after opening a terminal buffer + TermOpen = "TermOpen", + -- after setting "v:termresponse" + TermResponse = "TermResponse", + -- text was modified + TextChanged = "TextChanged", + -- text was modified in Insert mode(no popup) + TextChangedI = "TextChangedI", + -- text was modified in Insert mode(popup) + TextChangedP = "TextChangedP", + -- after a yank or delete was done (y, d, c) + TextYankPost = "TextYankPost", + -- after UI attaches + UIEnter = "UIEnter", + -- after UI detaches + UILeave = "UILeave", + -- user defined autocommand + User = "User", + -- whenthe user presses the same key 42 times + UserGettingBored = "UserGettingBored", + -- after starting Vim + VimEnter = "VimEnter", + -- before exiting Vim + VimLeave = "VimLeave", + -- before exiting Vim and writing ShaDa file + VimLeavePre = "VimLeavePre", + -- after Vim window was resized + VimResized = "VimResized", + -- after Nvim is resumed + VimResume = "VimResume", + -- before Nvim is suspended + VimSuspend = "VimSuspend", + -- after closing a window + WinClosed = "WinClosed", + -- after entering a window + WinEnter = "WinEnter", + -- before leaving a window + WinLeave = "WinLeave", + -- when entering a new window + WinNew = "WinNew", + -- after scrolling a window + WinScrolled = "WinScrolled", + + -- alias for `BufAdd` + BufCreate = "BufAdd", + -- alias for `BufReadPost` + BufRead = "BufReadPost", + -- alias for `BufWritePre` + BufWrite = "BufWritePre", + -- alias for `EncodingChanged` + FileEncoding = "EncodingChanged", + }, + buf = { + storage = buf_storage.create("nui.utils.autocmd", { _next_handler_id = 1 }), + }, +} + +---@param callback fun(event: table): nil +---@param bufnr integer +local function to_stored_handler(callback, bufnr) + local handler_id = autocmd.buf.storage[bufnr]._next_handler_id + autocmd.buf.storage[bufnr]._next_handler_id = handler_id + 1 + + autocmd.buf.storage[bufnr][handler_id] = callback + + local command = string.format(":lua require('nui.utils.autocmd').execute_stored_handler(%s, %s)", bufnr, handler_id) + + return command +end + +---@param bufnr integer +---@param handler_id number +function autocmd.execute_stored_handler(bufnr, handler_id) + local handler = autocmd.buf.storage[bufnr][handler_id] + if is_type("function", handler) then + handler() + end +end + +---@param name string +---@param opts { clear?: boolean } +function autocmd.create_group(name, opts) + if feature.lua_autocmd then + return vim.api.nvim_create_augroup(name, opts) + end + + vim.cmd(string.format( + [[ + augroup %s + %s + augroup end + ]], + name, + opts.clear and "autocmd!" or "" + )) +end + +---@param name string +function autocmd.delete_group(name) + if feature.lua_autocmd then + return vim.api.nvim_del_augroup_by_name(name) + end + + vim.cmd(string.format( + [[ + autocmd! %s + augroup! %s + ]], + name, + name + )) +end + +---@param event string|string[] +---@param opts table +---@param bufnr? integer # to store callback if lua autocmd is not available +function autocmd.create(event, opts, bufnr) + if feature.lua_autocmd then + return vim.api.nvim_create_autocmd(event, opts) + end + + event = is_type("table", event) and table.concat(event, ",") or event + local pattern = is_type("table", opts.pattern) and table.concat(opts.pattern, ",") or opts.pattern + if opts.buffer then + pattern = string.format("", opts.buffer) + end + + if opts.callback then + local buffer = opts.buffer or bufnr + if not buffer then + error("[nui.utils.autocmd] missing param: bufnr") + end + opts.command = to_stored_handler(opts.callback, buffer) + end + + vim.cmd( + string.format( + "autocmd %s %s %s %s %s %s", + opts.group or "", + event, + pattern, + opts.once and "++once" or "", + opts.nested and "++nested" or "", + opts.command + ) + ) +end + +---@param opts table +function autocmd.delete(opts) + if feature.lua_autocmd then + for _, item in ipairs(vim.api.nvim_get_autocmds(opts)) do + if item.id then + vim.api.nvim_del_autocmd(item.id) + end + end + + return + end + + local event = is_type("table", opts.event) and table.concat(opts.event, ",") or opts.event + local pattern = is_type("table", opts.pattern) and table.concat(opts.pattern, ",") or opts.pattern + if opts.buffer then + pattern = string.format("", opts.buffer) + end + + vim.cmd(string.format("autocmd! %s %s %s", opts.group or "", event or "*", pattern or "")) +end + +---@param event string|string[] +---@param opts table +function autocmd.exec(event, opts) + local events = is_type("table", event) and event or { event } + + if feature.lua_autocmd then + vim.api.nvim_exec_autocmds(events, { + group = opts.group, + pattern = opts.pattern, + buffer = opts.buffer, + modeline = opts.modeline, + data = opts.data, + }) + + return + end + + for _, event_name in ipairs(events) do + local command = string.format( + [[doautocmd %s %s %s %s]], + opts.modeline == false and "" or "", + opts.group or "", + event_name, + opts.pattern or "" + ) + + if opts.buffer then + vim.api.nvim_buf_call(opts.buffer, function() + vim.cmd(command) + end) + else + vim.cmd(command) + end + end +end + +-- @deprecated +---@deprecated +---@param event string | string[] +---@param pattern string | string[] +---@param cmd string +---@param options nil | table<"'once'" | "'nested'", boolean> +function autocmd.define(event, pattern, cmd, options) + local opts = options or {} + opts.pattern = pattern + opts.command = cmd + autocmd.create(event, opts) +end + +-- @deprecated +---@deprecated +---@param group_name string +---@param auto_clear boolean +---@param definitions table<"'event'" | "'pattern'" | "'cmd'" | "'options'", any> +function autocmd.define_grouped(group_name, auto_clear, definitions) + if not is_type("boolean", auto_clear) then + error("invalid param type: auto_clear, expected boolean") + end + + autocmd.create_group(group_name, { clear = auto_clear }) + + for _, definition in ipairs(definitions) do + autocmd.define(definition.event, definition.pattern, definition.cmd, definition.options) + end +end + +-- @deprecated +---@deprecated +---@param group_name nil | string +---@param event nil | string | string[] +---@param pattern nil | string | string[] +function autocmd.remove(group_name, event, pattern) + autocmd.delete({ + event = event, + group = group_name, + pattern = pattern, + }) +end + +---@param bufnr number +---@param event string | string[] +---@param handler string | function +---@param options nil | table<"'once'" | "'nested'", boolean> +function autocmd.buf.define(bufnr, event, handler, options) + local opts = options or {} + + opts.buffer = bufnr + + if is_type("function", handler) then + opts.callback = handler + else + opts.command = handler + end + + autocmd.create(event, opts, bufnr) +end + +---@param bufnr number +---@param group_name nil | string +---@param event nil | string | string[] +function autocmd.buf.remove(bufnr, group_name, event) + autocmd.delete({ + buffer = bufnr, + event = event, + group = group_name, + }) +end + +return autocmd diff --git a/bundle/nui.nvim/lua/nui/utils/buf_storage.lua b/bundle/nui.nvim/lua/nui/utils/buf_storage.lua new file mode 100644 index 000000000..4845b6498 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/utils/buf_storage.lua @@ -0,0 +1,33 @@ +local defaults = require("nui.utils").defaults + +local buf_storage = { + _registry = {}, +} + +---@param storage_name string +---@param default_value any +---@return table +function buf_storage.create(storage_name, default_value) + local storage = setmetatable({}, { + __index = function(tbl, bufnr) + rawset(tbl, bufnr, vim.deepcopy(defaults(default_value, {}))) + + -- TODO: can `buf_storage.cleanup` be automatically (and reliably) triggered on `BufWipeout`? + + return tbl[bufnr] + end, + }) + + buf_storage._registry[storage_name] = storage + + return storage +end + +---@param bufnr number +function buf_storage.cleanup(bufnr) + for _, storage in pairs(buf_storage._registry) do + rawset(storage, bufnr, nil) + end +end + +return buf_storage diff --git a/bundle/nui.nvim/lua/nui/utils/init.lua b/bundle/nui.nvim/lua/nui/utils/init.lua new file mode 100644 index 000000000..b1e4fbe2d --- /dev/null +++ b/bundle/nui.nvim/lua/nui/utils/init.lua @@ -0,0 +1,288 @@ +-- internal utils +local _ = { + feature = { + lua_keymap = type(vim.keymap) ~= "nil", + lua_autocmd = type(vim.api.nvim_create_autocmd) ~= "nil", + }, +} + +local utils = { + _ = _, +} + +function utils.get_editor_size() + return { + width = vim.o.columns, + height = vim.o.lines, + } +end + +function utils.get_window_size(winid) + winid = winid or 0 + return { + width = vim.api.nvim_win_get_width(winid), + height = vim.api.nvim_win_get_height(winid), + } +end + +function utils.defaults(v, default_value) + return type(v) == "nil" and default_value or v +end + +-- luacheck: push no max comment line length +---@param type_name "'nil'" | "'number'" | "'string'" | "'boolean'" | "'table'" | "'function'" | "'thread'" | "'userdata'" | "'list'" | '"map"' +function utils.is_type(type_name, v) + if type_name == "list" then + return vim.tbl_islist(v) + end + + if type_name == "map" then + return type(v) == "table" and not vim.tbl_islist(v) + end + + return type(v) == type_name +end +-- luacheck: pop + +---@param v string | number +function utils.parse_number_input(v) + local parsed = {} + + parsed.is_percentage = type(v) == "string" and string.sub(v, -1) == "%" + + if parsed.is_percentage then + parsed.value = tonumber(string.sub(v, 1, #v - 1)) / 100 + else + parsed.value = tonumber(v) + end + + return parsed +end + +---@param prefix? string +---@return (fun(): string) get_next_id +local function get_id_generator(prefix) + prefix = prefix or "" + local id = 0 + return function() + id = id + 1 + return prefix .. id + end +end + +_.get_next_id = get_id_generator("nui_") + +---@private +---@param bufnr number +---@param linenr number line number (1-indexed) +---@param char_start number start character position (0-indexed) +---@param char_end number end character position (0-indexed) +---@return number[] byte_range +function _.char_to_byte_range(bufnr, linenr, char_start, char_end) + local line = vim.api.nvim_buf_get_lines(bufnr, linenr - 1, linenr, false)[1] + local skipped_part = vim.fn.strcharpart(line, 0, char_start) + local target_part = vim.fn.strcharpart(line, char_start, char_end - char_start) + + local byte_start = vim.fn.strlen(skipped_part) + local byte_end = math.min(byte_start + vim.fn.strlen(target_part), vim.fn.strlen(line)) + return { byte_start, byte_end } +end + +---@type integer +local fallback_namespace_id = vim.api.nvim_create_namespace("nui.nvim") + +---@private +---@param ns_id integer +---@return integer +function _.ensure_namespace_id(ns_id) + return ns_id == -1 and fallback_namespace_id or ns_id +end + +---@private +---@param ns_id? integer|string +---@return integer ns_id namespace id +function _.normalize_namespace_id(ns_id) + if utils.is_type("string", ns_id) then + ---@cast ns_id string + return vim.api.nvim_create_namespace(ns_id) + end + ---@cast ns_id integer + return ns_id or fallback_namespace_id +end + +---@private +---@param bufnr integer +---@param ns_id integer +---@param linenr_start? integer (1-indexed) +---@param linenr_end? integer (1-indexed,inclusive) +function _.clear_namespace(bufnr, ns_id, linenr_start, linenr_end) + linenr_start = linenr_start or 1 + linenr_end = linenr_end and linenr_end + 1 or 0 + vim.api.nvim_buf_clear_namespace(bufnr, ns_id, linenr_start - 1, linenr_end - 1) +end + +---@private +---@param bufnr number +---@param buf_options table +function _.set_buf_options(bufnr, buf_options) + for name, value in pairs(buf_options) do + vim.api.nvim_buf_set_option(bufnr, name, value) + end +end + +---@private +---@param winid number +---@param win_options table +function _.set_win_options(winid, win_options) + for name, value in pairs(win_options) do + vim.api.nvim_win_set_option(winid, name, value) + end +end + +---@private +---@param dimension number | string +---@param container_dimension number +---@return nil | number +function _.normalize_dimension(dimension, container_dimension) + local number = utils.parse_number_input(dimension) + + if not number.value then + return nil + end + + if number.is_percentage then + return math.floor(container_dimension * number.value) + end + + return number.value +end + +---@param text string +---@param max_length number +---@return string +function _.truncate_text(text, max_length) + if vim.api.nvim_strwidth(text) > max_length then + return string.sub(text, 1, max_length - 1) .. "…" + end + + return text +end + +---@param text NuiText +---@param max_width number +function _.truncate_nui_text(text, max_width) + text:set(_.truncate_text(text:content(), max_width)) +end + +---@param line NuiLine +---@param max_width number +function _.truncate_nui_line(line, max_width) + local width = line:width() + local last_part_idx = #line._texts + + while width > max_width do + local extra_width = width - max_width + local last_part = line._texts[last_part_idx] + + if last_part:width() <= extra_width then + width = width - last_part:width() + line._texts[last_part_idx] = nil + last_part_idx = last_part_idx - 1 + + -- need to add truncate indicator in previous part + if last_part:width() == extra_width then + last_part = line._texts[last_part_idx] + last_part:set(_.truncate_text(last_part:content() .. " ", last_part:width())) + end + else + last_part:set(_.truncate_text(last_part:content(), last_part:width() - extra_width)) + width = width - extra_width + end + end +end + +---@param align "'left'" | "'center'" | "'right'" +---@param total_width number +---@param text_width number +---@return number left_gap_width, number right_gap_width +function _.calculate_gap_width(align, total_width, text_width) + local gap_width = total_width - text_width + if align == "left" then + return 0, gap_width + elseif align == "center" then + return math.floor(gap_width / 2), math.ceil(gap_width / 2) + elseif align == "right" then + return gap_width, 0 + end + + error("invalid value align=" .. align) +end + +---@param lines table[] NuiLine[] +---@param bufnr number +---@param ns_id number +---@param linenr_start number +---@param linenr_end number +function _.render_lines(lines, bufnr, ns_id, linenr_start, linenr_end) + vim.api.nvim_buf_set_lines( + bufnr, + linenr_start - 1, + linenr_end - 1, + false, + vim.tbl_map(function(line) + return line:content() + end, lines) + ) + + for linenr, line in ipairs(lines) do + line:highlight(bufnr, ns_id, linenr) + end +end + +function _.normalize_layout_options(options) + if utils.is_type("string", options.relative) then + options.relative = { + type = options.relative, + } + end + + if options.position and not utils.is_type("table", options.position) then + options.position = { + row = options.position, + col = options.position, + } + end + + if options.size and not utils.is_type("table", options.size) then + options.size = { + width = options.size, + height = options.size, + } + end + + return options +end + +---@param winhighlight string +---@return table highlight_map +function _.parse_winhighlight(winhighlight) + local highlight = {} + local parts = vim.split(winhighlight, ",", { plain = true, trimempty = true }) + for _, part in ipairs(parts) do + local key, value = part:match("(.+):(.+)") + highlight[key] = value + end + return highlight +end + +---@param highlight_map table +---@return string winhighlight +function _.serialize_winhighlight(highlight_map) + local parts = vim.tbl_map(function(key) + return key .. ":" .. highlight_map[key] + end, vim.tbl_keys(highlight_map)) + table.sort(parts) + return table.concat(parts, ",") +end + +return utils diff --git a/bundle/nui.nvim/lua/nui/utils/keymap.lua b/bundle/nui.nvim/lua/nui/utils/keymap.lua new file mode 100644 index 000000000..b4eee9a11 --- /dev/null +++ b/bundle/nui.nvim/lua/nui/utils/keymap.lua @@ -0,0 +1,145 @@ +local buf_storage = require("nui.utils.buf_storage") +local is_type = require("nui.utils").is_type +local feature = require("nui.utils")._.feature + +local keymap = { + storage = buf_storage.create("nui.utils.keymap", { _next_handler_id = 1, keys = {}, handlers = {} }), +} + +---@param mode string +---@param key string +---@return string key_id +local function get_key_id(mode, key) + return string.format("%s---%s", mode, vim.api.nvim_replace_termcodes(key, true, true, true)) +end + +---@param bufnr number +---@param key_id string +---@return number|nil handler_id +local function get_handler_id(bufnr, key_id) + return keymap.storage[bufnr].keys[key_id] +end + +---@param bufnr number +---@param key_id string +---@return number|nil handler_id +local function next_handler_id(bufnr, key_id) + local handler_id = keymap.storage[bufnr]._next_handler_id + keymap.storage[bufnr].keys[key_id] = handler_id + keymap.storage[bufnr]._next_handler_id = handler_id + 1 + return handler_id +end + +---@param bufnr number +---@param mode string +---@param key string +---@param handler string|fun(): nil +---@return { rhs: string, callback?: fun(): nil }|nil +local function get_keymap_info(bufnr, mode, key, handler, overwrite) + local key_id = get_key_id(mode, key) + + -- luacov: disable + if get_handler_id(bufnr, key_id) and not overwrite then + return nil + end + -- luacov: enable + + local handler_id = next_handler_id(bufnr, key_id) + + local rhs, callback = "", nil + + if is_type("function", handler) then + if feature.lua_keymap then + callback = handler + else + keymap.storage[bufnr].handlers[handler_id] = handler + rhs = string.format("lua require('nui.utils.keymap').execute(%s, %s)", bufnr, handler_id) + end + else + rhs = handler + end + + return { + rhs = rhs, + callback = callback, + } +end + +---@param bufnr number +---@param handler_id number +function keymap.execute(bufnr, handler_id) + local handler = keymap.storage[bufnr].handlers[handler_id] + if is_type("function", handler) then + handler(bufnr) + end +end + +---@param bufnr number +---@param mode string +---@param lhs string|string[] +---@param handler string|fun(): nil +---@param opts table<"'expr'"|"'noremap'"|"'nowait'"|"'remap'"|"'script'"|"'silent'"|"'unique'", boolean> +---@return nil +function keymap.set(bufnr, mode, lhs, handler, opts, force) + if feature.lua_keymap and not is_type("boolean", force) then + force = true + end + + local keys = is_type("table", lhs) and lhs or { lhs } + + opts = opts or {} + + if not is_type("nil", opts.remap) then + opts.noremap = not opts.remap + opts.remap = nil + end + + for _, key in ipairs(keys) do + local keymap_info = get_keymap_info(bufnr, mode, key, handler, force) + -- luacov: disable + if not keymap_info then + return false + end + -- luacov: enable + + local options = vim.deepcopy(opts) + options.callback = keymap_info.callback + + vim.api.nvim_buf_set_keymap(bufnr, mode, key, keymap_info.rhs, options) + end + + return true +end + +---@param bufnr number +---@param mode string +---@param lhs string|string[] +---@return nil +function keymap._del(bufnr, mode, lhs, force) + if feature.lua_keymap and not is_type("boolean", force) then + force = true + end + + local keys = is_type("table", lhs) and lhs or { lhs } + + for _, key in ipairs(keys) do + local key_id = get_key_id(mode, key) + + local handler_id = get_handler_id(bufnr, key_id) + + -- luacov: disable + if not handler_id and not force then + return false + end + -- luacov: enable + + keymap.storage[bufnr].keys[key_id] = nil + keymap.storage[bufnr].handlers[handler_id] = nil + + vim.api.nvim_buf_del_keymap(bufnr, mode, key) + end + + return true +end + +return keymap diff --git a/bundle/nui.nvim/nui.nvim-dev-1.rockspec b/bundle/nui.nvim/nui.nvim-dev-1.rockspec new file mode 100644 index 000000000..5661c290b --- /dev/null +++ b/bundle/nui.nvim/nui.nvim-dev-1.rockspec @@ -0,0 +1,27 @@ +rockspec_format = "3.0" +package = "nui.nvim" +version = "dev-1" +source = { + url = "git+https://github.com/MunifTanjim/nui.nvim.git", + tag = nil, +} +description = { + summary = "UI Component Library for Neovim.", + detailed = [[ + UI Component Library for Neovim. + ]], + license = "MIT", + homepage = "https://github.com/MunifTanjim/nui.nvim", + issues_url = "https://github.com/MunifTanjim/nui.nvim/issues", + maintainer = "Munif Tanjim (https://muniftanjim.dev)", + labels = { + "neovim", + }, +} +build = { + type = "builtin", +} +test = { + type = "command", + command = "scripts/test.sh", +} diff --git a/bundle/nui.nvim/scripts/format.sh b/bundle/nui.nvim/scripts/format.sh new file mode 100644 index 000000000..cdec07145 --- /dev/null +++ b/bundle/nui.nvim/scripts/format.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh + +r_v_major="0" +r_v_minor="13" +r_v_patch="1" + +v="$(stylua --version | cut -d' ' -f2)" +v_major="$(echo "${v}" | cut -d'.' -f1)" +v_minor="$(echo "${v}" | cut -d'.' -f2)" +v_patch="$(echo "${v}" | cut -d'.' -f3)" + +v_error_message="required stylua ~v${r_v_major}.${r_v_minor}.${r_v_patch}, found v${v_major}.${v_minor}.${v_patch}" + +if test ${v_major} -ne ${r_v_major} || test ${v_minor} -ne ${r_v_minor} || test ${v_patch} -lt ${r_v_patch}; then + echo ${v_error_message} >&2 + exit 1 +fi + +stylua --color always ${1} lua/nui/ tests/ diff --git a/bundle/nui.nvim/scripts/lint.sh b/bundle/nui.nvim/scripts/lint.sh new file mode 100644 index 000000000..706c7eb08 --- /dev/null +++ b/bundle/nui.nvim/scripts/lint.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +luacheck $@ . diff --git a/bundle/nui.nvim/scripts/plenary-353.patch b/bundle/nui.nvim/scripts/plenary-353.patch new file mode 100644 index 000000000..646b010f9 --- /dev/null +++ b/bundle/nui.nvim/scripts/plenary-353.patch @@ -0,0 +1,35 @@ +diff --git a/lua/plenary/busted.lua b/lua/plenary/busted.lua +index 1b15fce..8363084 100644 +--- a/lua/plenary/busted.lua ++++ b/lua/plenary/busted.lua +@@ -238,7 +238,7 @@ mod.run = function(file) + -- If nothing runs (empty file without top level describe) + if not results.pass then + if is_headless then +- return vim.cmd "0cq" ++ os.exit(0) + else + return + end +@@ -259,7 +259,7 @@ mod.run = function(file) + end + else + if is_headless then +- return vim.cmd "0cq" ++ os.exit(0) + end + end + end)() +diff --git a/lua/plenary/test_harness.lua b/lua/plenary/test_harness.lua +index 394e28d..66cc6b4 100644 +--- a/lua/plenary/test_harness.lua ++++ b/lua/plenary/test_harness.lua +@@ -169,7 +169,7 @@ function harness.test_directory(directory, opts) + return vim.cmd "1cq" + end + +- return vim.cmd "0cq" ++ os.exit(0) + end + end + diff --git a/bundle/nui.nvim/scripts/test.sh b/bundle/nui.nvim/scripts/test.sh new file mode 100644 index 000000000..a0e3a7554 --- /dev/null +++ b/bundle/nui.nvim/scripts/test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +set -euo pipefail + +test_scope="nui" + +while [[ $# -gt 0 ]]; do + case "${1}" in + --clean) + shift + echo "[test] cleaning up environment" + rm -rf ./.testcache + echo "[test] envionment cleaned" + ;; + *) + if [[ "${test_scope}" == "nui" ]] && [[ "${1}" == "nui/"* ]]; then + test_scope="${1}" + fi + shift + ;; + esac +done + +function setup_environment() { + echo + echo "[test] setting up environment" + echo + + local plugins_dir="./.testcache/site/pack/deps/start" + if [[ ! -d "${plugins_dir}" ]]; then + mkdir -p "${plugins_dir}" + fi + + if [[ ! -d "${plugins_dir}/plenary.nvim" ]]; then + echo "[plugins] plenary.nvim: installing..." + git clone https://github.com/nvim-lua/plenary.nvim "${plugins_dir}/plenary.nvim" + # commit 9069d14a120cadb4f6825f76821533f2babcab92 broke luacov + # issue: https://github.com/nvim-lua/plenary.nvim/issues/353 + local -r plenary_353_patch="$(pwd)/scripts/plenary-353.patch" + git -C "${plugins_dir}/plenary.nvim" apply "${plenary_353_patch}" + echo "[plugins] plenary.nvim: installed" + echo + fi + + echo "[test] environment ready" + echo +} + +function luacov_start() { + luacov_dir="$(dirname "$(luarocks which luacov 2>/dev/null | head -1)")" + if [[ "${luacov_dir}" == "." ]]; then + luacov_dir="" + fi + + if test -n "${luacov_dir}"; then + rm -f luacov.*.out + export LUA_PATH=";;${luacov_dir}/?.lua" + fi +} + +function luacov_end() { + if test -n "${luacov_dir}"; then + luacov + + echo + tail -n +$(($(grep -n "^Summary$" luacov.report.out | cut -d":" -f1) - 1)) luacov.report.out + fi +} + +setup_environment + +luacov_start + +if [[ -d "./tests/${test_scope}/" ]]; then + nvim --headless --noplugin -u tests/minimal_init.lua -c "lua require('plenary.test_harness').test_directory('./tests/${test_scope}/', { minimal_init = 'tests/minimal_init.lua', sequential = true })" +elif [[ -f "./tests/${test_scope}_spec.lua" ]]; then + nvim --headless --noplugin -u tests/minimal_init.lua -c "lua require('plenary.busted').run('./tests/${test_scope}_spec.lua')" +fi + +luacov_end diff --git a/bundle/nui.nvim/tests/helpers/init.lua b/bundle/nui.nvim/tests/helpers/init.lua new file mode 100644 index 000000000..7aefbc174 --- /dev/null +++ b/bundle/nui.nvim/tests/helpers/init.lua @@ -0,0 +1,306 @@ +local function to_string(text) + if type(text) == "string" then + return text + end + + if type(text) == "table" and text.content then + return text:content() + end + + error("unsupported text") +end + +local popup = {} + +local mod = {} + +mod.popup = popup + +function mod.eq(...) + return assert.are.same(...) +end + +function mod.approx(...) + return assert.are.near(...) +end + +function mod.neq(...) + return assert["not"].are.same(...) +end + +---@param fn fun(): nil +---@param error string +---@param is_plain boolean +function mod.errors(fn, error, is_plain) + assert.matches_error(fn, error, 1, is_plain) +end + +---@param keys string +---@param mode string +function mod.feedkeys(keys, mode) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), mode or "", true) +end + +---@param tbl table +---@param keys string[] +function mod.tbl_pick(tbl, keys) + if not keys or #keys == 0 then + return tbl + end + + local new_tbl = {} + for _, key in ipairs(keys) do + new_tbl[key] = tbl[key] + end + return new_tbl +end + +---@param tbl table +---@param keys string[] +function mod.tbl_omit(tbl, keys) + if not keys or #keys == 0 then + return tbl + end + + local new_tbl = vim.deepcopy(tbl) + for _, key in ipairs(keys) do + rawset(new_tbl, key, nil) + end + return new_tbl +end + +---@param bufnr number +---@param ns_id integer +---@param linenr integer (1-indexed) +---@param byte_start? integer (0-indexed) +---@param byte_end? integer (0-indexed, inclusive) +function mod.get_line_extmarks(bufnr, ns_id, linenr, byte_start, byte_end) + return vim.api.nvim_buf_get_extmarks( + bufnr, + ns_id, + { linenr - 1, byte_start or 0 }, + { linenr - 1, byte_end and byte_end + 1 or -1 }, + { details = true } + ) +end + +---@param bufnr number +---@param ns_id integer +---@param linenr integer (1-indexed) +---@param text string +---@return table[] +---@return { byte_start: integer, byte_end: integer } info (byte range: 0-indexed, inclusive) +function mod.get_text_extmarks(bufnr, ns_id, linenr, text) + local line = vim.api.nvim_buf_get_lines(bufnr, linenr - 1, linenr, false)[1] + + local byte_start = string.find(line, text) -- 1-indexed + byte_start = byte_start - 1 -- 0-indexed + local byte_end = byte_start + #text - 1 -- inclusive + + local extmarks = vim.api.nvim_buf_get_extmarks( + bufnr, + ns_id, + { linenr - 1, byte_start }, + { linenr - 1, byte_end }, + { details = true } + ) + + return extmarks, { byte_start = byte_start, byte_end = byte_end } +end + +---@param bufnr number +---@param lines string[] +---@param linenr_start? integer (1-indexed) +---@param linenr_end? integer (1-indexed, inclusive) +function mod.assert_buf_lines(bufnr, lines, linenr_start, linenr_end) + mod.eq(vim.api.nvim_buf_get_lines(bufnr, linenr_start and linenr_start - 1 or 0, linenr_end or -1, false), lines) +end + +---@param bufnr number +---@param options table +function mod.assert_buf_options(bufnr, options) + for name, value in pairs(options) do + mod.eq(vim.api.nvim_buf_get_option(bufnr, name), value) + end +end + +---@param winid number +---@param options table +function mod.assert_win_options(winid, options) + for name, value in pairs(options) do + mod.eq(vim.api.nvim_win_get_option(winid, name), value) + end +end + +---@param extmark table +---@param linenr number (1-indexed) +---@param text string +---@param hl_group string +function mod.assert_extmark(extmark, linenr, text, hl_group) + mod.eq(extmark[2], linenr - 1) + + if text then + local start_col = extmark[3] + mod.eq(extmark[4].end_col - start_col, #text) + end + + mod.eq(mod.tbl_pick(extmark[4], { "end_row", "hl_group" }), { + end_row = linenr - 1, + hl_group = hl_group, + }) +end + +---@param bufnr number +---@param ns_id integer +---@param linenr integer (1-indexed) +---@param text string +---@param hl_group string +function mod.assert_highlight(bufnr, ns_id, linenr, text, hl_group) + local extmarks, info = mod.get_text_extmarks(bufnr, ns_id, linenr, text) + + mod.eq(#extmarks, 1) + mod.eq(extmarks[1][3], info.byte_start) + mod.assert_extmark(extmarks[1], linenr, text, hl_group) +end + +---@param feature_name string +---@param desc string +---@param func fun(is_available: boolean):nil +function mod.describe_flipping_feature(feature_name, desc, func) + local initial_value = require("nui.utils")._.feature[feature_name] + + describe(string.format("(w/ %s) %s", feature_name, desc), function() + require("nui.utils")._.feature[feature_name] = true + func(true) + require("nui.utils")._.feature[feature_name] = initial_value + end) + + describe(string.format("(w/o %s) %s", feature_name, desc), function() + require("nui.utils")._.feature[feature_name] = false + func(false) + require("nui.utils")._.feature[feature_name] = initial_value + end) +end + +function popup.create_border_style_list() + return { "╭", "─", "╮", "│", "╯", "─", "╰", "│" } +end + +function popup.create_border_style_map() + return { + top_left = "╭", + top = "─", + top_right = "╮", + left = "│", + right = "│", + bottom_left = "╰", + bottom = "─", + bottom_right = "╯", + } +end + +function popup.create_border_style_map_with_tuple(hl_group) + local style = popup.create_border_style_map() + for k, v in pairs(style) do + style[k] = { v, hl_group .. "_" .. k } + end + return style +end + +function popup.create_border_style_map_with_nui_text(hl_group) + local Text = require("nui.text") + + local style = popup.create_border_style_map() + for k, v in pairs(style) do + style[k] = Text(v, hl_group .. "_" .. k) + end + + return style +end + +function popup.assert_border_lines(options, border_bufnr) + local size = { width = options.size.width, height = options.size.height } + + local style = vim.deepcopy(options.border.style) + if vim.tbl_islist(style) then + style = { + top_left = style[1], + top = style[2], + top_right = style[3], + left = style[8], + right = style[4], + bottom_left = style[7], + bottom = style[6], + bottom_right = style[5], + } + end + + local expected_lines = {} + table.insert( + expected_lines, + string.format( + "%s%s%s", + to_string(style.top_left), + string.rep(to_string(style.top), size.width), + to_string(style.top_right) + ) + ) + for _ = 1, size.height do + table.insert( + expected_lines, + string.format("%s%s%s", to_string(style.left), string.rep(" ", size.width), to_string(style.right)) + ) + end + table.insert( + expected_lines, + string.format( + "%s%s%s", + to_string(style.bottom_left), + string.rep(to_string(style.bottom), size.width), + to_string(style.bottom_right) + ) + ) + + mod.assert_buf_lines(border_bufnr, expected_lines) +end + +function popup.assert_border_highlight(options, border_bufnr, hl_group, no_hl_group_suffix) + local size = { width = options.size.width, height = options.size.height } + + for linenr = 1, size.height + 2 do + local is_top_line = linenr == 1 + local is_bottom_line = linenr == size.height + 2 + + local extmarks = mod.get_line_extmarks(border_bufnr, options.ns_id, linenr) + + mod.eq(#extmarks, (is_top_line or is_bottom_line) and 4 or 2) + + local function with_suffix(hl_group_name, suffix) + if no_hl_group_suffix then + return hl_group_name + end + return hl_group_name .. suffix + end + + mod.assert_extmark( + extmarks[1], + linenr, + nil, + with_suffix(hl_group, (is_top_line and "_top_left" or is_bottom_line and "_bottom_left" or "_left")) + ) + + if is_top_line or is_bottom_line then + mod.assert_extmark(extmarks[2], linenr, nil, with_suffix(hl_group, (is_top_line and "_top" or "_bottom"))) + mod.assert_extmark(extmarks[3], linenr, nil, with_suffix(hl_group, (is_top_line and "_top" or "_bottom"))) + end + + mod.assert_extmark( + extmarks[#extmarks], + linenr, + nil, + with_suffix(hl_group, (is_top_line and "_top_right" or is_bottom_line and "_bottom_right" or "_right")) + ) + end +end + +return mod diff --git a/bundle/nui.nvim/tests/minimal_init.lua b/bundle/nui.nvim/tests/minimal_init.lua new file mode 100644 index 000000000..0d87b02c7 --- /dev/null +++ b/bundle/nui.nvim/tests/minimal_init.lua @@ -0,0 +1,26 @@ +-- mimic startup option `--clean` +local function clean_startup() + for _, path in ipairs(vim.split(vim.o.runtimepath, ",")) do + if + string.find(path, vim.fn.expand("~/.config/nvim")) + or string.find(path, vim.fn.expand("~/.local/share/nvim/site")) + then + vim.opt.packpath:remove(path) + vim.opt.runtimepath:remove(path) + end + end +end + +clean_startup() + +local root_dir = vim.fn.fnamemodify(vim.trim(vim.fn.system("git rev-parse --show-toplevel")), ":p") + +package.path = string.format("%s;%s?.lua;%s?/init.lua", package.path, root_dir, root_dir) + +vim.opt.packpath:prepend(root_dir .. ".testcache/site") + +vim.opt.runtimepath:prepend(root_dir) + +vim.cmd([[ + packadd plenary.nvim +]]) diff --git a/bundle/nui.nvim/tests/nui/input/init_spec.lua b/bundle/nui.nvim/tests/nui/input/init_spec.lua new file mode 100644 index 000000000..42faca084 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/input/init_spec.lua @@ -0,0 +1,159 @@ +pcall(require, "luacov") + +local Input = require("nui.input") +local Text = require("nui.text") +local h = require("tests.helpers") + +local eq, feedkeys = h.eq, h.feedkeys + +-- Input's functionalities are not testable using headless nvim. +-- Not sure what to do about it. + +describe("nui.input", function() + local parent_winid, parent_bufnr + local popup_options + local input + + before_each(function() + parent_winid = vim.api.nvim_get_current_win() + parent_bufnr = vim.api.nvim_get_current_buf() + + popup_options = { + relative = "win", + position = "50%", + size = 20, + } + end) + + after_each(function() + if input then + input:unmount() + input = nil + end + end) + + pending("o.prompt", function() + it("supports NuiText", function() + local prompt_text = "> " + local hl_group = "NuiInputTest" + + input = Input(popup_options, { + prompt = Text(prompt_text, hl_group), + }) + + input:mount() + + h.assert_buf_lines(input.bufnr, { + prompt_text, + }) + + h.assert_highlight(input.bufnr, input.ns_id, 1, prompt_text, hl_group) + end) + end) + + describe("cursor_position_patch", function() + local initial_cursor + + local function setup() + vim.api.nvim_buf_set_lines(parent_bufnr, 0, -1, false, { + "1 nui.nvim", + "2 nui.nvim", + "3 nui.nvim", + }) + initial_cursor = { 2, 4 } + vim.api.nvim_win_set_cursor(parent_winid, initial_cursor) + end + + it("works after submitting from insert mode", function() + setup() + + local done = false + input = Input(popup_options, { + on_submit = function() + done = true + end, + }) + + input:mount() + + feedkeys("", "x") + + vim.fn.wait(1000, function() + return done + end) + + eq(done, true) + eq(vim.api.nvim_win_get_cursor(parent_winid), initial_cursor) + end) + + it("works after submitting from normal mode", function() + setup() + + local done = false + input = Input(popup_options, { + on_submit = function() + done = true + end, + }) + + input:mount() + + feedkeys("", "x") + + vim.fn.wait(1000, function() + return done + end) + + eq(done, true) + eq(vim.api.nvim_win_get_cursor(parent_winid), initial_cursor) + end) + + it("works after closing from insert mode", function() + setup() + + local done = false + input = Input(popup_options, { + on_close = function() + done = true + end, + }) + + input:mount() + + input:map("i", "", input.input_props.on_close, { nowait = true, noremap = true }) + + feedkeys("i", "x") + + vim.fn.wait(1000, function() + return done + end) + + eq(done, true) + eq(vim.api.nvim_win_get_cursor(parent_winid), initial_cursor) + end) + + it("works after closing from normal mode", function() + setup() + + local done = false + input = Input(popup_options, { + on_close = function() + done = true + end, + }) + + input:mount() + + input:map("n", "", input.input_props.on_close, { nowait = true, noremap = true }) + + feedkeys("", "x") + + vim.fn.wait(1000, function() + return done + end) + + eq(done, true) + eq(vim.api.nvim_win_get_cursor(parent_winid), initial_cursor) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/layout/init_spec.lua b/bundle/nui.nvim/tests/nui/layout/init_spec.lua new file mode 100644 index 000000000..49b2f2202 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/layout/init_spec.lua @@ -0,0 +1,1872 @@ +pcall(require, "luacov") + +local Layout = require("nui.layout") +local Popup = require("nui.popup") +local Split = require("nui.split") +local h = require("tests.helpers") +local spy = require("luassert.spy") + +local eq, tbl_pick = h.eq, h.tbl_pick + +local function create_popups(...) + local popups = {} + for _, popup_options in ipairs({ ... }) do + table.insert(popups, Popup(popup_options)) + end + return popups +end + +local function create_splits(...) + local splits = {} + for _, split_options in ipairs({ ... }) do + table.insert(splits, Split(split_options)) + end + return splits +end + +local function percent(number, percentage) + return math.floor(number * percentage / 100) +end + +local function get_assert_component(layout) + local layout_winid = layout.winid + assert(layout_winid, "missing layout.winid, forgot to mount it?") + + return function(component, expected) + eq(type(component.bufnr), "number") + eq(type(component.winid), "number") + + local win_config, border_win_config = + vim.api.nvim_win_get_config(component.winid), + component.border.winid and vim.api.nvim_win_get_config(component.border.winid) + if border_win_config then + eq(border_win_config.relative, "win") + eq(border_win_config.win, layout_winid) + + eq(win_config.relative, "win") + eq(win_config.win, component.border.winid) + else + eq(win_config.relative, "win") + eq(win_config.win, layout_winid) + end + + if border_win_config then + local border_row, border_col = border_win_config.row[vim.val_idx], border_win_config.col[vim.val_idx] + eq(border_row, expected.position.row) + eq(border_col, expected.position.col) + + local row, col = win_config.row[vim.val_idx], win_config.col[vim.val_idx] + eq(row, border_row + math.floor(component.border._.size_delta.width / 2 + 0.5)) + eq(col, border_col + math.floor(component.border._.size_delta.height / 2 + 0.5)) + else + local row, col = win_config.row[vim.val_idx], win_config.col[vim.val_idx] + eq(row, expected.position.row) + eq(col, expected.position.col) + end + + local expected_width, expected_height = expected.size.width, expected.size.height + if component.border then + expected_width = expected_width - component.border._.size_delta.width + expected_height = expected_height - component.border._.size_delta.height + end + eq(vim.api.nvim_win_get_width(component.winid), expected_width) + eq(vim.api.nvim_win_get_height(component.winid), expected_height) + end +end + +describe("nui.layout", function() + local layout + + after_each(function() + if layout then + layout:unmount() + layout = nil + end + end) + + describe("param box", function() + it("throws if empty table", function() + local ok, err = pcall(function() + Layout({}, {}) + end) + + eq(ok, false) + eq(type(string.match(err, "unexpected empty box")), "string") + end) + + it("throws if empty box", function() + local ok, err = pcall(function() + Layout( + {}, + Layout.Box({ + Layout.Box({ + Layout.Box({}, { size = "50%" }), + Layout.Box({}, { size = "50%" }), + }, { size = "100%" }), + }) + ) + end) + + eq(ok, false) + eq(type(string.match(err, "unexpected empty box")), "string") + end) + + it("does not throw if non-empty", function() + local p1 = unpack(create_popups({})) + + local ok, err = pcall(function() + Layout( + { position = "50%", size = "100%" }, + Layout.Box({ + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box({}, { size = "50%" }), + }, { size = "100%" }), + }) + ) + end) + + eq(ok, true) + eq(err, nil) + end) + end) + + describe("box", function() + it("requires child.size if child.grow is missing", function() + local p1, p2 = unpack(create_popups({}, {})) + + local ok, result = pcall(function() + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box(p2, {}), + }) + end) + + eq(ok, false) + eq(type(string.match(result, "missing child.size")), "string") + end) + + it("does not require child.size if child.grow is present", function() + local p1, p2 = unpack(create_popups({}, {})) + + local ok = pcall(function() + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box(p2, { grow = 1 }), + }) + end) + + eq(ok, true) + end) + + describe("size (table)", function() + it("missing height is set to 100% if dir=row", function() + local p1, p2 = unpack(create_popups({}, {})) + + local box = Layout.Box({ + Layout.Box(p1, { size = { width = "40%" } }), + Layout.Box(p2, { size = { width = "60%", height = "80%" } }), + }, { dir = "row" }) + + eq(box.box[1].size, { + width = "40%", + height = "100%", + }) + eq(box.box[2].size, { + width = "60%", + height = "80%", + }) + end) + + it("missing width is set to 100% if dir=col", function() + local p1, p2 = unpack(create_popups({}, {})) + + local box = Layout.Box({ + Layout.Box(p1, { size = { height = "40%" } }), + Layout.Box(p2, { size = { width = "60%", height = "80%" } }), + }, { dir = "col" }) + + eq(box.box[1].size, { + width = "100%", + height = "40%", + }) + eq(box.box[2].size, { + width = "60%", + height = "80%", + }) + end) + end) + + describe("size (percentage string)", function() + it("is set to width if dir=row", function() + local p1, p2 = unpack(create_popups({}, {})) + + local box = Layout.Box({ + Layout.Box(p1, { size = "40%" }), + Layout.Box(p2, { size = "60%" }), + }, { dir = "row" }) + + eq(box.box[1].size, { + width = "40%", + height = "100%", + }) + eq(box.box[2].size, { + width = "60%", + height = "100%", + }) + end) + + it("is set to height if dir=col", function() + local p1, p2 = unpack(create_popups({}, {})) + + local box = Layout.Box({ + Layout.Box(p1, { size = "40%" }), + Layout.Box(p2, { size = "60%" }), + }, { dir = "col" }) + + eq(box.box[1].size, { + width = "100%", + height = "40%", + }) + eq(box.box[2].size, { + width = "100%", + height = "60%", + }) + end) + end) + end) + + describe("[float]", function() + describe("o.size", function() + local box + + before_each(function() + local p1 = unpack(create_popups({})) + box = Layout.Box({ Layout.Box(p1, { size = "100%" }) }) + end) + + local function assert_size(size) + local win_config = vim.api.nvim_win_get_config(layout.winid) + + eq(tbl_pick(win_config, { "width", "height" }), { + width = math.floor(size.width), + height = math.floor(size.height), + }) + end + + it("supports number", function() + local size = 20 + + layout = Layout({ + position = "50%", + size = size, + }, box) + + layout:mount() + + assert_size({ width = size, height = size }) + end) + + it("supports percentage string", function() + local percentage = 50 + + layout = Layout({ + position = "50%", + size = string.format("%s%%", percentage), + }, box) + + local winid = vim.api.nvim_get_current_win() + local win_width = vim.api.nvim_win_get_width(winid) + local win_height = vim.api.nvim_win_get_height(winid) + + layout:mount() + + assert_size({ + width = win_width * percentage / 100, + height = win_height * percentage / 100, + }) + end) + + it("supports table", function() + local width = 10 + local height_percentage = 50 + + layout = Layout({ + position = "50%", + size = { + width = width, + height = string.format("%s%%", height_percentage), + }, + }, box) + + local winid = vim.api.nvim_get_current_win() + local win_height = vim.api.nvim_win_get_height(winid) + + layout:mount() + + assert_size({ + width = width, + height = win_height * height_percentage / 100, + }) + end) + end) + + describe("o.position", function() + local box + + before_each(function() + local p1 = unpack(create_popups({})) + box = Layout.Box({ Layout.Box(p1, { size = "100%" }) }) + end) + + local function assert_position(position) + local row, col = unpack(vim.api.nvim_win_get_position(layout.winid)) + + eq(row, math.floor(position.row)) + eq(col, math.floor(position.col)) + end + + it("supports number", function() + local position = 5 + + layout = Layout({ + position = position, + size = 10, + }, box) + + layout:mount() + + assert_position({ row = position, col = position }) + end) + + it("supports percentage string", function() + local size = 10 + local percentage = 50 + + layout = Layout({ + position = string.format("%s%%", percentage), + size = size, + }, box) + + layout:mount() + + local winid = vim.api.nvim_get_current_win() + local win_width = vim.api.nvim_win_get_width(winid) + local win_height = vim.api.nvim_win_get_height(winid) + + assert_position({ + row = (win_height - size) * percentage / 100, + col = (win_width - size) * percentage / 100, + }) + end) + + it("supports table", function() + local size = 10 + local row = 5 + local col_percentage = 50 + + layout = Layout({ + position = { + row = row, + col = string.format("%s%%", col_percentage), + }, + size = size, + }, box) + + layout:mount() + + local winid = vim.api.nvim_get_current_win() + local win_width = vim.api.nvim_win_get_width(winid) + + assert_position({ + row = row, + col = (win_width - size) * col_percentage / 100, + }) + end) + end) + + describe("method :mount", function() + it("mounts all components", function() + local p1, p2 = unpack(create_popups({}, {})) + + local p1_mount = spy.on(p1, "mount") + local p2_mount = spy.on(p2, "mount") + + layout = Layout( + { + position = "50%", + size = { + height = 20, + width = 100, + }, + }, + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box(p2, { size = "50%" }), + }) + ) + + layout:mount() + + eq(type(layout.bufnr), "number") + eq(type(layout.winid), "number") + + assert.spy(p1_mount).was_called() + assert.spy(p2_mount).was_called() + end) + + it("is idempotent", function() + local p1, p2 = unpack(create_popups({}, {})) + + local p1_mount = spy.on(p1, "mount") + local p2_mount = spy.on(p2, "mount") + + layout = Layout( + { + position = "50%", + size = 20, + }, + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box(p2, { size = "50%" }), + }) + ) + + layout:mount() + + assert.spy(p1_mount).was_called(1) + assert.spy(p2_mount).was_called(1) + + layout:mount() + + assert.spy(p1_mount).was_called(1) + assert.spy(p2_mount).was_called(1) + end) + + it("supports container component", function() + local p1, p2 = unpack(create_popups({}, {})) + + local split = Split({ + relative = "editor", + position = "bottom", + size = 10, + }) + + local split_mount = spy.on(split, "mount") + + layout = Layout( + split, + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box(p2, { size = "50%" }), + }) + ) + + layout:mount() + + assert.spy(split_mount).was_called(1) + + local win_config = vim.api.nvim_win_get_config(layout.winid) + eq(win_config.relative, "win") + eq(win_config.row[vim.val_idx], 0) + eq(win_config.col[vim.val_idx], 0) + eq(win_config.width, vim.o.columns) + eq(win_config.height, 10) + + split:unmount() + end) + + it("throws if missing config 'size'", function() + local p1 = unpack(create_popups({}, {})) + + local ok, result = pcall(function() + layout = Layout({}, { Layout.Box(p1, { size = "100%" }) }) + end) + + eq(ok, false) + eq(type(string.match(result, "missing layout config: size")), "string") + end) + + it("throws if missing config 'position'", function() + local p1 = unpack(create_popups({}, {})) + + local ok, result = pcall(function() + layout = Layout({ + size = "50%", + }, { Layout.Box(p1, { size = "100%" }) }) + end) + + eq(ok, false) + eq(type(string.match(result, "missing layout config: position")), "string") + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :unmount", function() + it("is called if any popup is unmounted", function() + local p1, p2 = unpack(create_popups({}, {}, {})) + + layout = Layout( + { + position = "50%", + size = 10, + }, + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box(p2, { size = "50%" }), + }) + ) + + local layout_unmount = spy.on(layout, "unmount") + + layout:mount() + + p2:unmount() + + vim.wait(100, function() + return not layout._.mounted + end, 10) + + assert.spy(layout_unmount).was_called() + end) + + it("is called if any popup is quitted", function() + local p1, p2 = unpack(create_popups({}, {})) + + layout = Layout( + { + position = "50%", + size = 10, + }, + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box(p2, { size = "50%" }), + }) + ) + + local layout_unmount = spy.on(layout, "unmount") + + layout:mount() + + vim.api.nvim_buf_call(p2.bufnr, function() + vim.cmd([[quit]]) + end) + + vim.wait(100, function() + return not layout._.mounted + end, 10) + + assert.spy(layout_unmount).was_called() + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :hide", function() + it("does nothing if not mounted", function() + local p1 = unpack(create_popups({})) + + local p1_hide = spy.on(p1, "hide") + + layout = Layout( + { + position = "50%", + size = 10, + }, + Layout.Box({ + Layout.Box(p1, { size = "100%" }), + }) + ) + + layout:hide() + + assert.spy(p1_hide).was_not_called() + end) + + it("hides all components", function() + local p1, p2, p3 = unpack(create_popups({}, {}, {})) + + local p1_hide = spy.on(p1, "hide") + local p2_hide = spy.on(p2, "hide") + local p3_hide = spy.on(p3, "hide") + + layout = Layout( + { + position = "50%", + size = 10, + }, + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box({ + Layout.Box(p2, { size = "50%" }), + Layout.Box({ + Layout.Box(p3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + layout:mount() + + eq(type(layout.winid), "number") + + layout:hide() + + eq(type(layout.winid), "nil") + + assert.spy(p1_hide).was_called() + assert.spy(p2_hide).was_called() + assert.spy(p3_hide).was_called() + end) + + it("is called if any popup is hidden", function() + local p1, p2, p3 = unpack(create_popups({}, {}, {})) + + layout = Layout( + { + position = "50%", + size = 10, + }, + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box({ + Layout.Box(p2, { size = "50%" }), + Layout.Box({ + Layout.Box(p3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + local layout_hide = spy.on(layout, "hide") + + layout:mount() + + p2:hide() + + assert.spy(layout_hide).was_called() + end) + end) + + describe("method :show", function() + it("does nothing if not mounted", function() + local p1 = unpack(create_popups({})) + + local p1_show = spy.on(p1, "show") + + layout = Layout( + { + position = "50%", + size = 10, + }, + Layout.Box({ + Layout.Box(p1, { size = "100%" }), + }) + ) + + layout:hide() + layout:show() + + assert.spy(p1_show).was_not_called() + end) + + it("shows all components", function() + local p1, p2, p3 = unpack(create_popups({}, {}, {})) + + local p1_show = spy.on(p1, "show") + local p2_show = spy.on(p2, "show") + local p3_show = spy.on(p3, "show") + + layout = Layout( + { + position = "50%", + size = 10, + }, + Layout.Box({ + Layout.Box(p1, { size = "50%" }), + Layout.Box({ + Layout.Box(p2, { size = "50%" }), + Layout.Box({ + Layout.Box(p3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + layout:mount() + + layout:hide() + layout:show() + + eq(type(layout.winid), "number") + + assert.spy(p1_show).was_called() + assert.spy(p2_show).was_called() + assert.spy(p3_show).was_called() + end) + end) + + describe("method :update", function() + local winid, win_width, win_height + local p1, p2, p3, p4 + local assert_component + + before_each(function() + winid = vim.api.nvim_get_current_win() + win_width = vim.api.nvim_win_get_width(winid) + win_height = vim.api.nvim_win_get_height(winid) + + p1, p2, p3, p4 = unpack(create_popups({}, {}, { + border = { + style = "rounded", + }, + }, {})) + end) + + local function get_initial_layout(config) + return Layout( + config, + Layout.Box({ + Layout.Box(p1, { size = "20%" }), + Layout.Box({ + Layout.Box(p3, { size = "50%" }), + Layout.Box(p4, { size = "50%" }), + }, { dir = "col", size = "60%" }), + Layout.Box(p2, { size = "20%" }), + }, { dir = "row" }) + ) + end + + local function assert_layout_config(config) + local relative, position, size = config.relative, config.position, config.size + + local win_config = vim.api.nvim_win_get_config(layout.winid) + eq(win_config.relative, relative.type) + eq(win_config.win, relative.winid) + + local row, col = unpack(vim.api.nvim_win_get_position(layout.winid)) + eq(row, position.row) + eq(col, position.col) + + eq(vim.api.nvim_win_get_width(layout.winid), size.width) + eq(vim.api.nvim_win_get_height(layout.winid), size.height) + end + + local function assert_initial_layout_components() + local size = { + width = vim.api.nvim_win_get_width(layout.winid), + height = vim.api.nvim_win_get_height(layout.winid), + } + + assert_component(p1, { + position = { + row = 0, + col = 0, + }, + size = { + width = percent(size.width, 20), + height = size.height, + }, + }) + + assert_component(p3, { + position = { + row = 0, + col = percent(size.width, 20), + }, + size = { + width = percent(size.width, 60), + height = percent(size.height, 50), + }, + }) + + assert_component(p4, { + position = { + row = percent(size.height, 50), + col = percent(size.width, 20), + }, + size = { + width = percent(size.width, 60), + height = percent(size.height, 50), + }, + }) + + assert_component(p2, { + position = { + row = 0, + col = percent(size.width, 20) + percent(size.width, 60), + }, + size = { + width = percent(size.width, 20), + height = size.height, + }, + }) + end + + it("processes layout correctly on mount", function() + local layout_update_spy = spy.on(Layout, "update") + + layout = get_initial_layout({ position = 0, size = "100%" }) + + layout:mount() + + layout_update_spy:revert() + assert.spy(layout_update_spy).was_called(1) + + local expected_layout_config = { + relative = { + type = "win", + winid = winid, + }, + position = { + row = 0, + col = 0, + }, + size = { + width = win_width, + height = win_height, + }, + } + + assert_layout_config(expected_layout_config) + + assert_component = get_assert_component(layout) + + assert_initial_layout_components() + end) + + it("can update layout win_config w/o rearranging boxes", function() + layout = get_initial_layout({ position = 0, size = "100%" }) + + layout:mount() + + layout:update({ + position = { + row = 2, + col = 4, + }, + size = "80%", + }) + + local expected_layout_config = { + relative = { + type = "win", + winid = winid, + }, + position = { + row = 2, + col = 4, + }, + size = { + width = percent(win_width, 80), + height = percent(win_height, 80), + }, + } + + assert_layout_config(expected_layout_config) + + assert_component = get_assert_component(layout) + + assert_initial_layout_components() + end) + + it("can rearrange boxes w/o changing layout win_config", function() + layout = get_initial_layout({ position = 0, size = "100%" }) + + layout:mount() + + layout:update(Layout.Box({ + Layout.Box(p2, { size = "30%" }), + Layout.Box({ + Layout.Box(p4, { size = "40%" }), + Layout.Box(p3, { size = "60%" }), + }, { dir = "row", size = "30%" }), + Layout.Box(p1, { size = "40%" }), + }, { dir = "col" })) + + local expected_layout_config = { + relative = { + type = "win", + winid = winid, + }, + position = { + row = 0, + col = 0, + }, + size = { + width = win_width, + height = win_height, + }, + } + + assert_layout_config(expected_layout_config) + + assert_component = get_assert_component(layout) + + assert_component(p2, { + position = { + row = 0, + col = 0, + }, + size = { + width = win_width, + height = percent(win_height, 30), + }, + }) + + assert_component(p4, { + position = { + row = percent(win_height, 30), + col = 0, + }, + size = { + width = percent(win_width, 40), + height = percent(win_height, 30), + }, + }) + + assert_component(p3, { + position = { + row = percent(win_height, 30), + col = percent(win_width, 40), + }, + size = { + width = percent(win_width, 60), + height = percent(win_height, 30), + }, + }) + + assert_component(p1, { + position = { + row = percent(win_height, 30) + percent(win_height, 30), + col = 0, + }, + size = { + width = win_width, + height = percent(win_height, 40), + }, + }) + end) + + it("refreshes layout if container size changes", function() + local popup = Popup({ + position = 0, + size = "100%", + }) + + popup:mount() + + layout = get_initial_layout({ + relative = { + type = "win", + winid = popup.winid, + }, + position = 0, + size = "80%", + }) + + layout:mount() + + local expected_layout_config = { + relative = { + type = "win", + winid = popup.winid, + }, + position = { + row = 0, + col = 0, + }, + size = { + width = percent(win_width, 80), + height = percent(win_height, 80), + }, + } + + assert_layout_config(expected_layout_config) + + assert_component = get_assert_component(layout) + + assert_initial_layout_components() + + popup:update_layout({ + size = "80%", + }) + + layout:update() + + expected_layout_config.size = { + width = percent(percent(win_width, 80), 80), + height = percent(percent(win_height, 80), 80), + } + + assert_layout_config(expected_layout_config) + + assert_initial_layout_components() + end) + + it("supports child with child.grow", function() + layout = get_initial_layout({ position = 0, size = "100%" }) + + layout:mount() + + layout:update(Layout.Box({ + Layout.Box(p1, { size = "20%" }), + Layout.Box({ + Layout.Box({}, { size = 4 }), + Layout.Box(p3, { grow = 1 }), + Layout.Box({}, { size = 8 }), + Layout.Box(p4, { grow = 1 }), + }, { dir = "col", size = "60%" }), + Layout.Box(p2, { grow = 1 }), + }, { dir = "row" })) + + local expected_layout_config = { + relative = { + type = "win", + winid = winid, + }, + position = { + row = 0, + col = 0, + }, + size = { + width = win_width, + height = win_height, + }, + } + + assert_layout_config(expected_layout_config) + + assert_component = get_assert_component(layout) + + assert_component(p1, { + position = { + row = 0, + col = 0, + }, + size = { + width = percent(win_width, 20), + height = win_height, + }, + }) + + assert_component(p3, { + position = { + row = 4, + col = percent(win_width, 20), + }, + size = { + width = percent(win_width, 60), + height = percent(win_height - 4 - 8, 100 / 2), + }, + }) + + assert_component(p4, { + position = { + row = 4 + 8 + percent(win_height - 4 - 8, 100 / 2), + col = percent(win_width, 20), + }, + size = { + width = percent(win_width, 60), + height = percent(win_height - 4 - 8, 100 / 2), + }, + }) + + assert_component(p2, { + position = { + row = 0, + col = percent(win_width, 20) + percent(win_width, 60), + }, + size = { + width = percent(win_width, 100 - 20 - 60), + height = win_height, + }, + }) + end) + + it("can change boxes", function() + layout = Layout( + { position = 0, size = "100%" }, + Layout.Box({ + Layout.Box(p1, { size = "40%" }), + Layout.Box(p2, { size = "60%" }), + }, { dir = "col" }) + ) + + layout:mount() + + assert_component = get_assert_component(layout) + + assert_component(p1, { + position = { + row = 0, + col = 0, + }, + size = { + width = win_width, + height = percent(win_height, 40), + }, + }) + + assert_component(p2, { + position = { + row = percent(win_height, 40), + col = 0, + }, + size = { + width = win_width, + height = percent(win_height, 60), + }, + }) + + layout:update(Layout.Box({ + Layout.Box({ + Layout.Box(p1, { size = "40%" }), + Layout.Box(p2, { size = "60%" }), + }, { dir = "col", size = "60%" }), + Layout.Box(p3, { size = "40%" }), + }, { dir = "row" })) + + assert_component = get_assert_component(layout) + + assert_component(p1, { + position = { + row = 0, + col = 0, + }, + size = { + width = percent(win_width, 60), + height = percent(win_height, 40), + }, + }) + + assert_component(p2, { + position = { + row = percent(win_height, 40), + col = 0, + }, + size = { + width = percent(win_width, 60), + height = percent(win_height, 60), + }, + }) + + assert_component(p3, { + position = { + row = 0, + col = percent(win_width, 60), + }, + size = { + width = percent(win_width, 40), + height = win_height, + }, + }) + + layout:update(Layout.Box({ + Layout.Box({ + Layout.Box(p1, { size = "40%" }), + Layout.Box(p2, { size = "60%" }), + }, { dir = "col", size = "60%" }), + Layout.Box(p4, { size = "40%" }), + }, { dir = "row" })) + + assert_component(p4, { + position = { + row = 0, + col = percent(win_width, 60), + }, + size = { + width = percent(win_width, 40), + height = win_height, + }, + }) + + eq(p3.winid, nil) + + layout:update(Layout.Box({ + Layout.Box(p3, { size = "40%" }), + Layout.Box(p4, { size = "60%" }), + }, { dir = "col" })) + + eq(p1.winid, nil) + eq(p2.winid, nil) + + assert_component(p3, { + position = { + row = 0, + col = 0, + }, + size = { + width = win_width, + height = percent(win_height, 40), + }, + }) + + assert_component(p4, { + position = { + row = percent(win_height, 40), + col = 0, + }, + size = { + width = win_width, + height = percent(win_height, 60), + }, + }) + end) + + it("positions popup with complex border correctly", function() + p1 = unpack(create_popups({ + border = { + style = "single", + text = { + top = "text", + }, + padding = { 1 }, + }, + })) + + layout = Layout( + { position = 0, size = "100%" }, + Layout.Box({ + Layout.Box(p1, { size = "100%" }), + }, { dir = "col" }) + ) + + layout:mount() + + assert_component = get_assert_component(layout) + + assert_component(p1, { + position = { + row = 0, + col = 0, + }, + size = { + width = win_width, + height = percent(win_height, 100), + }, + }) + end) + end) + end) + + describe("[split]", function() + local function assert_size(winid, expected, tolerance) + h.approx(vim.api.nvim_win_get_width(winid), expected.width, tolerance or 0) + h.approx(vim.api.nvim_win_get_height(winid), expected.height, tolerance or 0) + end + + describe("method :mount", function() + it("mounts all components", function() + local s1, s2, s3 = unpack(create_splits({}, {}, {})) + + local s1_mount = spy.on(s1, "mount") + local s2_mount = spy.on(s2, "mount") + local s3_mount = spy.on(s3, "mount") + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "50%" }), + Layout.Box({ + Layout.Box(s2, { size = "50%" }), + Layout.Box({ + Layout.Box(s3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + layout:mount() + + eq(type(layout.bufnr), "nil") + eq(type(layout.winid), "nil") + + assert.spy(s1_mount).was_called() + assert.spy(s2_mount).was_called() + assert.spy(s3_mount).was_called() + end) + + it("is idempotent", function() + local s1, s2 = unpack(create_splits({}, {})) + + local s1_mount = spy.on(s1, "mount") + local s2_mount = spy.on(s2, "mount") + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "50%" }), + Layout.Box(s2, { size = "50%" }), + }) + ) + + layout:mount() + + assert.spy(s1_mount).was_called(1) + assert.spy(s2_mount).was_called(1) + + layout:mount() + + assert.spy(s1_mount).was_called(1) + assert.spy(s2_mount).was_called(1) + end) + + it("mounts with correct layout", function() + local winid = vim.api.nvim_get_current_win() + + local s1, s2, s3, s4, s5 = unpack(create_splits({}, {}, {}, {}, {})) + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "100%" }), + Layout.Box({ + Layout.Box({ + Layout.Box(s2, { size = "100%" }), + }, { dir = "col", size = "60%" }), + Layout.Box({ + Layout.Box(s3, { size = "40%" }), + Layout.Box(s4, { size = "60%" }), + }, { dir = "row", size = "40%" }), + Layout.Box({}, { size = "0%" }), + }, { dir = "col", size = "40%" }), + Layout.Box(s5, { size = "35%" }), + }) + ) + + layout:mount() + + eq(vim.fn.winlayout(), { + "col", + { + { "leaf", winid }, + { + "row", + { + { "leaf", s1.winid }, + { + "col", + { + { "leaf", s2.winid }, + { + "row", + { + { "leaf", s3.winid }, + { "leaf", s4.winid }, + }, + }, + }, + }, + { "leaf", s5.winid }, + }, + }, + }, + }) + end) + + it("mounts with acceptable sizes", function() + local winid = vim.api.nvim_get_current_win() + local base_size = { + width = vim.api.nvim_win_get_width(winid), + height = vim.api.nvim_win_get_height(winid), + } + + local s1, s2, s3, s4, s5 = unpack(create_splits({}, {}, {}, {}, {})) + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "25%" }), + Layout.Box({ + Layout.Box({ + Layout.Box(s2, { size = "100%" }), + }, { dir = "col", size = "60%" }), + Layout.Box({ + Layout.Box(s3, { size = "40%" }), + Layout.Box(s4, { size = "60%" }), + }, { dir = "row", size = "40%" }), + Layout.Box({}, { size = "0%" }), + }, { dir = "col", size = "40%" }), + Layout.Box(s5, { size = "35%" }), + }) + ) + + layout:mount() + + assert_size(s1.winid, { + width = percent(base_size.width, 25), + height = percent(20, 100), + }, 2) + + assert_size(s2.winid, { + width = percent(base_size.width, 40), + height = percent(20, 60), + }, 1) + + assert_size(s3.winid, { + width = percent(percent(base_size.width, 40), 40), + height = percent(20, 40), + }, 1) + + assert_size(s4.winid, { + width = percent(percent(base_size.width, 40), 60), + height = percent(20, 40), + }, 1) + + assert_size(s5.winid, { + width = percent(base_size.width, 35), + height = percent(20, 100), + }) + end) + end) + + describe("method :unmount", function() + it("unmounts all components", function() + local s1, s2, s3 = unpack(create_splits({}, {}, {})) + + local s1_unmount = spy.on(s1, "unmount") + local s2_unmount = spy.on(s2, "unmount") + local s3_unmount = spy.on(s3, "unmount") + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "50%" }), + Layout.Box({ + Layout.Box(s2, { size = "50%" }), + Layout.Box({ + Layout.Box(s3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + layout:mount() + layout:unmount() + + assert.spy(s1_unmount).was_called() + assert.spy(s2_unmount).was_called() + assert.spy(s3_unmount).was_called() + end) + + it("is called if any split is unmounted", function() + local s1, s2, s3 = unpack(create_splits({}, {}, {})) + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "50%" }), + Layout.Box({ + Layout.Box(s2, { size = "50%" }), + Layout.Box({ + Layout.Box(s3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + local layout_unmount = spy.on(layout, "unmount") + + layout:mount() + + s2:unmount() + + vim.wait(100, function() + return not layout._.mounted + end, 10) + + assert.spy(layout_unmount).was_called() + end) + + it("is called if any split is quitted", function() + local s1, s2, s3 = unpack(create_splits({}, {}, {})) + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "50%" }), + Layout.Box({ + Layout.Box(s2, { size = "50%" }), + Layout.Box({ + Layout.Box(s3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + local layout_unmount = spy.on(layout, "unmount") + + layout:mount() + + vim.api.nvim_buf_call(s2.bufnr, function() + vim.cmd([[quit]]) + end) + + vim.wait(100, function() + return not layout._.mounted + end, 10) + + assert.spy(layout_unmount).was_called() + end) + end) + + describe("method :hide", function() + it("does nothing if not mounted", function() + local s1 = unpack(create_splits({})) + + local s1_hide = spy.on(s1, "hide") + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "100%" }), + }) + ) + + layout:hide() + + assert.spy(s1_hide).was_not_called() + end) + + it("hides all components", function() + local s1, s2, s3 = unpack(create_splits({}, {}, {})) + + local s1_hide = spy.on(s1, "hide") + local s2_hide = spy.on(s2, "hide") + local s3_hide = spy.on(s3, "hide") + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "50%" }), + Layout.Box({ + Layout.Box(s2, { size = "50%" }), + Layout.Box({ + Layout.Box(s3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + layout:mount() + + layout:hide() + + assert.spy(s1_hide).was_called() + assert.spy(s2_hide).was_called() + assert.spy(s3_hide).was_called() + end) + + it("is called if any split is hidden", function() + local s1, s2, s3 = unpack(create_splits({}, {}, {})) + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "50%" }), + Layout.Box({ + Layout.Box(s2, { size = "50%" }), + Layout.Box({ + Layout.Box(s3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + local layout_hide = spy.on(layout, "hide") + + layout:mount() + + s2:hide() + + assert.spy(layout_hide).was_called() + end) + end) + + describe("method :show", function() + it("does nothing if not mounted", function() + local s1 = unpack(create_splits({})) + + local s1_show = spy.on(s1, "show") + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "100%" }), + }) + ) + + layout:hide() + layout:show() + + assert.spy(s1_show).was_not_called() + end) + + it("shows all components", function() + local s1, s2, s3 = unpack(create_splits({}, {}, {})) + + local s1_show = spy.on(s1, "show") + local s2_show = spy.on(s2, "show") + local s3_show = spy.on(s3, "show") + + layout = Layout( + { + position = "bottom", + size = 20, + }, + Layout.Box({ + Layout.Box(s1, { size = "50%" }), + Layout.Box({ + Layout.Box(s2, { size = "50%" }), + Layout.Box({ + Layout.Box(s3, { size = "100%" }), + }, { size = "50%" }), + }, { size = "50%" }), + }) + ) + + layout:mount() + + layout:hide() + layout:show() + + assert.spy(s1_show).was_called() + assert.spy(s2_show).was_called() + assert.spy(s3_show).was_called() + end) + end) + + describe("method :update", function() + local winid, base_size + local s1, s2, s3, s4 + + before_each(function() + winid = vim.api.nvim_get_current_win() + base_size = { + width = vim.api.nvim_win_get_width(winid), + height = vim.api.nvim_win_get_height(winid), + } + + s1, s2, s3, s4 = unpack(create_splits({}, {}, {}, {})) + end) + + local function get_initial_layout(config) + return Layout( + config, + Layout.Box({ + Layout.Box(s1, { size = "20%" }), + Layout.Box({ + Layout.Box(s2, { size = "40%" }), + Layout.Box(s3, { size = "60%" }), + }, { dir = "col", size = "50%" }), + Layout.Box(s4, { size = "30%" }), + }, { dir = "row" }) + ) + end + + it("can update layout win_config w/o rearranging boxes", function() + layout = get_initial_layout({ + position = "bottom", + size = 10, + }) + + layout:mount() + + assert_size(s1.winid, { + width = percent(base_size.width, 20), + height = percent(10, 100), + }, 2) + + assert_size(s2.winid, { + width = percent(base_size.width, 50), + height = percent(10, 40), + }, 1) + + assert_size(s3.winid, { + width = percent(base_size.width, 50), + height = percent(10, 60), + }) + + assert_size(s4.winid, { + width = percent(base_size.width, 30), + height = percent(10, 100), + }) + + layout:update({ size = 20 }) + + assert_size(s1.winid, { + width = percent(base_size.width, 20), + height = percent(20, 100), + }, 2) + + assert_size(s2.winid, { + width = percent(base_size.width, 50), + height = percent(20, 40), + }, 2) + + assert_size(s3.winid, { + width = percent(base_size.width, 50), + height = percent(20, 60), + }, 2) + + assert_size(s4.winid, { + width = percent(base_size.width, 30), + height = percent(20, 100), + }) + end) + + it("supports child with child.grow", function() + layout = get_initial_layout({ + position = "bottom", + size = 10, + }) + + layout:update(Layout.Box({ + Layout.Box(s1, { size = 20 }), + Layout.Box({ + Layout.Box(s2, { grow = 1 }), + Layout.Box(s3, { grow = 2 }), + }, { dir = "col", grow = 2 }), + Layout.Box(s4, { grow = 1 }), + }, { dir = "row" })) + + layout:mount() + + assert_size(s1.winid, { + width = 20, + height = 10, + }, 2) + + assert_size(s2.winid, { + width = ((base_size.width - 20) / (2 + 1)) * 2, + height = (10 / (1 + 2)) * 1, + }, 1) + + assert_size(s3.winid, { + width = ((base_size.width - 20) / (2 + 1)) * 2, + height = (10 / (1 + 2)) * 2, + }, 1) + + assert_size(s4.winid, { + width = ((base_size.width - 20) / (2 + 1)) * 1, + height = 10, + }) + end) + + it("can change boxes", function() + layout = Layout( + { position = "bottom", size = 10 }, + Layout.Box({ + Layout.Box(s1, { size = "40%" }), + Layout.Box(s2, { size = "60%" }), + }, { dir = "row" }) + ) + + layout:mount() + + eq(vim.fn.winlayout(), { + "col", + { + { "leaf", winid }, + { + "row", + { + { "leaf", s1.winid }, + { "leaf", s2.winid }, + }, + }, + }, + }) + + layout:update(Layout.Box({ + Layout.Box({ + Layout.Box(s1, { size = "40%" }), + Layout.Box(s2, { size = "60%" }), + }, { dir = "col", size = "60%" }), + Layout.Box(s3, { size = "40%" }), + }, { dir = "row" })) + + eq(vim.fn.winlayout(), { + "col", + { + { "leaf", winid }, + { + "row", + { + { + "col", + { + { "leaf", s1.winid }, + { "leaf", s2.winid }, + }, + }, + { "leaf", s3.winid }, + }, + }, + }, + }) + + layout:update(Layout.Box({ + Layout.Box({ + Layout.Box(s1, { size = "40%" }), + Layout.Box(s2, { size = "60%" }), + }, { dir = "col", size = "60%" }), + Layout.Box(s4, { size = "40%" }), + }, { dir = "row" })) + + eq(vim.fn.winlayout(), { + "col", + { + { "leaf", winid }, + { + "row", + { + { + "col", + { + { "leaf", s1.winid }, + { "leaf", s2.winid }, + }, + }, + { "leaf", s4.winid }, + }, + }, + }, + }) + + eq(s3.winid, nil) + end) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/layout/utils_spec.lua b/bundle/nui.nvim/tests/nui/layout/utils_spec.lua new file mode 100644 index 000000000..e8bc779f2 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/layout/utils_spec.lua @@ -0,0 +1,142 @@ +pcall(require, "luacov") + +local utils = require("nui.layout.utils") +local h = require("tests.helpers") + +local eq = h.eq + +describe("nui.layout", function() + describe("utils", function() + describe("parse_relative", function() + local fallback_winid = 17 + + it("works for type=buf", function() + local relative = { + type = "buf", + position = { row = 2, col = 4 }, + winid = 42, + } + + local result = utils.parse_relative(relative, fallback_winid) + + eq(result, { + relative = "win", + win = relative.winid, + bufpos = { + relative.position.row, + relative.position.col, + }, + }) + end) + + it("works for type=cursor", function() + local relative = { + type = "cursor", + winid = 42, + } + + local result = utils.parse_relative(relative, fallback_winid) + + eq(result, { + relative = relative.type, + win = relative.winid, + }) + end) + + it("works for type=editor", function() + local relative = { + type = "editor", + winid = 42, + } + + local result = utils.parse_relative(relative, fallback_winid) + + eq(result, { + relative = relative.type, + win = relative.winid, + }) + end) + + it("works for type=win", function() + local relative = { + type = "win", + winid = 42, + } + + local result = utils.parse_relative(relative, fallback_winid) + + eq(result, { + relative = relative.type, + win = relative.winid, + }) + end) + + it("uses fallback_winid if relative.winid is nil", function() + local relative = { + type = "win", + } + + local result = utils.parse_relative(relative, fallback_winid) + + eq(result, { + relative = relative.type, + win = fallback_winid, + }) + end) + end) + + describe("get_container_info", function() + it("works for relative=win", function() + local result = utils.get_container_info({ + relative = "editor", + }) + + eq(result, { + relative = "editor", + size = { + width = vim.o.columns, + height = vim.o.lines, + }, + type = "editor", + }) + end) + + it("works for relative=cursor", function() + local winid = vim.api.nvim_get_current_win() + + local result = utils.get_container_info({ + relative = "cursor", + win = winid, + }) + + eq(result, { + relative = "cursor", + size = { + width = vim.api.nvim_win_get_width(winid), + height = vim.api.nvim_win_get_height(winid), + }, + type = "window", + }) + end) + + it("works for relative=win w/ bufpos", function() + local winid = vim.api.nvim_get_current_win() + + local result = utils.get_container_info({ + relative = "win", + win = winid, + bufpos = { 2, 4 }, + }) + + eq(result, { + relative = "buf", + size = { + width = vim.api.nvim_win_get_width(winid), + height = vim.api.nvim_win_get_height(winid), + }, + type = "window", + }) + end) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/line/init_spec.lua b/bundle/nui.nvim/tests/nui/line/init_spec.lua new file mode 100644 index 000000000..ba7c374f2 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/line/init_spec.lua @@ -0,0 +1,164 @@ +pcall(require, "luacov") + +local Line = require("nui.line") +local Text = require("nui.text") +local h = require("tests.helpers") + +local eq = h.eq + +describe("nui.line", function() + it("can accept initial nui.text objects", function() + local t1, t2 = Text("One"), Text("Two") + local line = Line({ t1, t2 }) + + eq(#line._texts, 2) + end) + + describe("method :append", function() + it("returns nui.text for string parameter", function() + local line = Line() + local text = line:append("One") + + eq(type(text.content), "function") + end) + + it("returns nui.text for nui.text parameter", function() + local line = Line() + local text = Text("One") + local ret_text = line:append(text) + + eq(text == ret_text, true) + eq(type(ret_text.content), "function") + end) + + it("returns nui.line for nui.line parameter", function() + local line = Line() + + local content_line = Line({ Text("One"), Text("Two") }) + + local ret_content_line = line:append(content_line) + + eq(content_line == ret_content_line, true) + eq(type(ret_content_line.append), "function") + end) + + it("stores and returns block with same reference", function() + local line = Line() + + local text_one = line:append("One") + + eq(line._texts[1] == text_one, true) + + local text_two = Text("Two") + local ret_text_two = line:append(text_two) + + eq(text_two == ret_text_two, true) + eq(line._texts[2] == text_two, true) + eq(line._texts[2] == ret_text_two, true) + + local text_three = Text("Three") + local text_four = Text("Four") + local content_line = Line({ text_three, text_four }) + local ret_content_line = line:append(content_line) + + eq(content_line == ret_content_line, true) + eq(line._texts[3] == content_line._texts[1], true) + eq(line._texts[4] == content_line._texts[2], true) + end) + end) + + describe("method :content", function() + it("returns whole text content", function() + local line = Line() + line:append("One") + line:append("Two") + + eq(line:content(), "OneTwo") + end) + end) + + describe("method :width", function() + it("returns whole text width", function() + local line = Line() + line:append("One") + line:append("Two") + + eq(line:width(), 6) + end) + end) + + describe("method", function() + local winid, bufnr + + before_each(function() + winid = vim.api.nvim_get_current_win() + bufnr = vim.api.nvim_create_buf(false, true) + + vim.api.nvim_win_set_buf(winid, bufnr) + end) + + after_each(function() + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + describe(":highlight", function() + local hl_group_one, hl_group_two, ns, ns_id + local linenr + local t1, t2, t3, t4 + local line + + before_each(function() + hl_group_one = "NuiTextTestOne" + hl_group_two = "NuiTextTestTwo" + ns = "NuiTest" + ns_id = vim.api.nvim_create_namespace(ns) + + linenr = 1 + + t1 = Text("One") + t2 = Text("Two", hl_group_one) + t3 = Text("Three", hl_group_two) + t4 = Text("Four") + + line = Line({ t1, t2, t3, t4 }) + end) + + it("is applied with :render", function() + line:render(bufnr, ns_id, linenr) + + h.assert_highlight(bufnr, ns_id, linenr, t2:content(), hl_group_one) + h.assert_highlight(bufnr, ns_id, linenr, t3:content(), hl_group_two) + end) + + it("can highlight existing buffer line", function() + vim.api.nvim_buf_set_lines( + bufnr, + linenr - 1, + -1, + false, + { t1:content() .. t2:content() .. t3:content() .. t4:content() } + ) + + line:highlight(bufnr, ns_id, linenr) + + h.assert_highlight(bufnr, ns_id, linenr, t2:content(), hl_group_one) + h.assert_highlight(bufnr, ns_id, linenr, t3:content(), hl_group_two) + end) + end) + + describe(":render", function() + it("works", function() + local linenr = 1 + + local line = Line() + line:append("4") + line:append("2") + line:render(bufnr, -1, linenr) + + h.assert_buf_lines(bufnr, { + "42", + }) + end) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/menu/init_spec.lua b/bundle/nui.nvim/tests/nui/menu/init_spec.lua new file mode 100644 index 000000000..ad42db0d8 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/menu/init_spec.lua @@ -0,0 +1,574 @@ +pcall(require, "luacov") + +local Menu = require("nui.menu") +local Line = require("nui.line") +local Text = require("nui.text") +local h = require("tests.helpers") +local spy = require("luassert.spy") + +local eq, feedkeys = h.eq, h.feedkeys + +describe("nui.menu", function() + local callbacks + local popup_options + local menu + + before_each(function() + callbacks = { + on_change = function() end, + on_submit = function() end, + } + + popup_options = { + relative = "win", + position = "50%", + } + end) + + after_each(function() + if menu then + menu:unmount() + menu = nil + end + end) + + describe("method :new", function() + it("works with menu", function() + menu = Menu:new(popup_options, { + lines = { + Menu.item("a"), + }, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "a", + }) + end) + end) + + describe("o.keymap", function() + it("supports multiple keys as table", function() + local on_change = spy.on(callbacks, "on_change") + + local lines = { + Menu.item("Item 1", { id = 1 }), + Menu.item("Item 2", { id = 2 }), + Menu.item("Item 3", { id = 3 }), + } + + menu = Menu(popup_options, { + keymap = { + focus_next = { "j", "s" }, + focus_prev = { "k", "w" }, + }, + lines = lines, + on_change = on_change, + }) + + menu:mount() + + feedkeys("j", "x") + assert.spy(on_change).called_with(lines[2], menu) + on_change:clear() + + feedkeys("s", "x") + assert.spy(on_change).called_with(lines[3], menu) + on_change:clear() + + feedkeys("w", "x") + assert.spy(on_change).called_with(lines[2], menu) + on_change:clear() + + feedkeys("k", "x") + assert.spy(on_change).called_with(lines[1], menu) + on_change:clear() + end) + + it("supports single key as string", function() + local on_change = spy.on(callbacks, "on_change") + + local lines = { + Menu.item("Item 1", { id = 1 }), + Menu.item("Item 2", { id = 2 }), + Menu.item("Item 3", { id = 3 }), + } + + menu = Menu(popup_options, { + keymap = { + focus_next = "s", + focus_prev = "w", + }, + lines = lines, + on_change = on_change, + }) + + menu:mount() + + feedkeys("s", "x") + assert.spy(on_change).called_with(lines[2], menu) + on_change:clear() + + feedkeys("w", "x") + assert.spy(on_change).called_with(lines[1], menu) + on_change:clear() + end) + end) + + describe("size", function() + it("respects o.min_width", function() + local min_width = 3 + + local items = { + Menu.item("A"), + Menu.separator("*"), + Menu.item("B"), + } + + menu = Menu(popup_options, { + lines = items, + min_width = min_width, + }) + + menu:mount() + + eq(vim.api.nvim_win_get_width(menu.winid), min_width) + + h.assert_buf_lines(menu.bufnr, { + "A", + " * ", + "B", + }) + end) + + it("respects o.max_width", function() + local max_width = 6 + + local items = { + Menu.item("Item 1"), + Menu.separator("*"), + Menu.item("Item Number Two"), + } + + menu = Menu(popup_options, { + lines = items, + max_width = max_width, + }) + + menu:mount() + + eq(vim.api.nvim_win_get_width(menu.winid), max_width) + + h.assert_buf_lines(menu.bufnr, { + "Item 1", + " * ", + "Item …", + }) + end) + + it("respects o.min_height", function() + local min_height = 3 + + local items = { + Menu.item("A"), + Menu.separator("*"), + Menu.item("B"), + } + + menu = Menu(popup_options, { + lines = items, + min_height = min_height, + }) + + menu:mount() + + eq(vim.api.nvim_win_get_height(menu.winid), min_height) + end) + + it("respects o.max_height", function() + local max_height = 2 + + local items = { + Menu.item("A"), + Menu.separator("*"), + Menu.item("B"), + } + + menu = Menu(popup_options, { + lines = items, + max_height = max_height, + }) + + menu:mount() + + eq(vim.api.nvim_win_get_height(menu.winid), max_height) + end) + end) + + it("calls o.on_change item focus is changed", function() + local on_change = spy.on(callbacks, "on_change") + + local lines = { + Menu.item("Item 1", { id = 1 }), + Menu.item("Item 2", { id = 2 }), + } + + menu = Menu(popup_options, { + lines = lines, + on_change = on_change, + }) + + menu:mount() + + -- initial focus + assert.spy(on_change).called_with(lines[1], menu) + on_change:clear() + + feedkeys("j", "x") + assert.spy(on_change).called_with(lines[2], menu) + on_change:clear() + + feedkeys("j", "x") + assert.spy(on_change).called_with(lines[1], menu) + on_change:clear() + + feedkeys("k", "x") + assert.spy(on_change).called_with(lines[2], menu) + on_change:clear() + end) + + it("calls o.on_submit when item is submitted", function() + local on_submit = spy.on(callbacks, "on_submit") + + local lines = { + Menu.item("Item 1", { id = 1 }), + Menu.item("Item 2", { id = 2 }), + } + + menu = Menu(popup_options, { + lines = lines, + on_submit = on_submit, + }) + + menu:mount() + + feedkeys("j", "x") + feedkeys("", "x") + + assert.spy(on_submit).called_with(lines[2]) + end) + + it("calls o.on_close when menu is closed", function() + local on_close = spy.on(callbacks, "on_close") + + local lines = { + Menu.item("Item 1", { id = 1 }), + Menu.item("Item 2", { id = 2 }), + } + + menu = Menu(popup_options, { + lines = lines, + on_close = on_close, + }) + + menu:mount() + + feedkeys("", "x") + + assert.spy(on_close).called_with() + end) + + describe("item", function() + it("is prepared using o.prepare_item if provided", function() + local items = { + Menu.item("A"), + Menu.separator("*"), + Menu.item("B"), + } + + local function prepare_item(item) + return "-" .. item.text .. "-" + end + + menu = Menu(popup_options, { + lines = items, + prepare_item = prepare_item, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, vim.tbl_map(prepare_item, items)) + end) + + it("is prepared when o.prepare_item is not provided", function() + local items = { + Menu.item("A"), + Menu.separator("*"), + Menu.item("B"), + } + + popup_options.border = "single" + + menu = Menu(popup_options, { + lines = items, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "A", + "─*──", + "B", + }) + end) + + it("is skipped respecting o.should_skip_item if provided", function() + local on_change = spy.on(callbacks, "on_change") + + local items = { + Menu.item("-"), + Menu.item("A", { id = 1 }), + Menu.item("-"), + Menu.item("B", { id = 2 }), + Menu.item("-"), + } + + menu = Menu(popup_options, { + lines = items, + on_change = on_change, + should_skip_item = function(item) + return not item.id + end, + }) + + menu:mount() + + assert.spy(on_change).called_with(items[2], menu) + on_change:clear() + + feedkeys("j", "x") + assert.spy(on_change).called_with(items[4], menu) + on_change:clear() + + feedkeys("j", "x") + assert.spy(on_change).called_with(items[2], menu) + on_change:clear() + end) + + it("supports table with key .text", function() + local text = "text" + + local items = { + Menu.item({ text = text }), + } + + menu = Menu(popup_options, { + lines = items, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + text, + }) + end) + + it("supports nui.text", function() + local hl_group = "NuiMenuTest" + local text = "text" + local items = { + Menu.item(Text(text, hl_group)), + } + + menu = Menu(popup_options, { + lines = items, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + text, + }) + + h.assert_highlight(menu.bufnr, menu.ns_id, 1, text, hl_group) + end) + + it("supports nui.line", function() + local hl_group = "NuiMenuTest" + local text = "text" + local items = { + Menu.item(Line({ Text(text, hl_group) })), + } + + menu = Menu(popup_options, { + lines = items, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + text, + }) + + h.assert_highlight(menu.bufnr, menu.ns_id, 1, text, hl_group) + end) + + it("content longer than max_width is truncated", function() + local items = { + Menu.item({ text = "Item 10 -" }), + Menu.item(Text("Item 20 -")), + Menu.item(Line({ Text("Item 30 -") })), + Menu.item(Line({ Text("Item 40"), Text(" -") })), + Menu.item(Line({ Text("Item 50 -"), Text(" -") })), + } + + menu = Menu(popup_options, { + max_width = 7, + lines = items, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "Item 1…", + "Item 2…", + "Item 3…", + "Item 4…", + "Item 5…", + }) + end) + end) + + describe("separator", function() + it("text supports string", function() + menu = Menu(popup_options, { + lines = { + Menu.item("A"), + Menu.separator("Group"), + }, + min_width = 10, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "A", + " Group ", + }) + end) + + it("content longer than max_width is truncated", function() + menu = Menu(popup_options, { + lines = { + Menu.item("A"), + Menu.separator("Long Long Group"), + }, + max_width = 10, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "A", + " Long Lo… ", + }) + end) + + it("text supports nui.text", function() + local hl_group = "NuiMenuTest" + local text = "Group" + + menu = Menu(popup_options, { + lines = { + Menu.item("A"), + Menu.separator(Text(text, hl_group)), + }, + min_width = 10, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "A", + " Group ", + }) + + h.assert_highlight(menu.bufnr, menu.ns_id, 2, text, hl_group) + end) + + it("text supports nui.line", function() + local hl_group = "NuiMenuTest" + local text = "Group" + + menu = Menu(popup_options, { + lines = { + Menu.item("A"), + Menu.separator(Line({ Text(text, hl_group), Text(" nui.text") })), + }, + min_width = 10, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "A", + " Group nui.t… ", + }) + + h.assert_highlight(menu.bufnr, menu.ns_id, 2, text, hl_group) + end) + + it("o.char supports string", function() + menu = Menu(popup_options, { + lines = { + Menu.item("A"), + Menu.separator("Group", { + char = "*", + text_align = "right", + }), + }, + min_width = 10, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "A", + "****Group*", + }) + end) + + it("o.char supports nui.text", function() + local hl_group = "NuiMenuTest" + + menu = Menu(popup_options, { + lines = { + Menu.item("A"), + Menu.separator("Group", { + char = Text("*", hl_group), + text_align = "center", + }), + }, + min_width = 10, + }) + + menu:mount() + + h.assert_buf_lines(menu.bufnr, { + "A", + "**Group***", + }) + + local linenr = 2 + + local extmarks = h.get_line_extmarks(menu.bufnr, menu.ns_id, linenr) + + eq(#extmarks, 4) + h.assert_extmark(extmarks[1], linenr, "*", hl_group) + h.assert_extmark(extmarks[2], linenr, "*", hl_group) + h.assert_extmark(extmarks[3], linenr, "**", hl_group) + h.assert_extmark(extmarks[4], linenr, "*", hl_group) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/object/init_spec.lua b/bundle/nui.nvim/tests/nui/object/init_spec.lua new file mode 100644 index 000000000..9abc9140d --- /dev/null +++ b/bundle/nui.nvim/tests/nui/object/init_spec.lua @@ -0,0 +1,413 @@ +pcall(require, "luacov") + +local h = require("tests.helpers") +local Object = require("nui.object") +local spy = require("luassert.spy") + +local function assert_class(Class, SuperClass, name) + h.eq(type(Class), "table") + + h.eq(Class.super, SuperClass) + h.eq(Class.name, name) + h.eq(tostring(Class), "class " .. name) + + h.eq(type(Class.new), "function") + h.eq(type(Class.extend), "function") + + local is_callable = pcall(function() + return Class() + end) + h.eq(is_callable, true) +end + +local function assert_instance(instance, Class) + h.eq(instance.class, Class) + h.eq(tostring(instance), "instance of class " .. Class.name) + + h.eq(instance.name, nil) + h.eq(instance.super, nil) + h.eq(instance.static, nil) + + h.eq(instance.new, nil) + h.eq(instance.extend, nil) +end + +local function create_classes(...) + local by_name = {} + local classes = {} + + for i, def in ipairs({ ... }) do + if type(def) == "string" then + local class = Object(def) + assert_class(class, nil, def) + by_name[def] = class + classes[i] = class + elseif type(def) == "table" then + local super = type(def[2]) == "table" and def[2] or (by_name[def[2]] and by_name[def[2]] or nil) + local class = super and super:extend(def[1]) or Object(def[1]) + assert_class(class, super, def[1]) + by_name[def[1]] = class + classes[i] = class + else + error("invalid argument") + end + end + + return unpack(classes) +end + +describe("nui.object", function() + describe("class", function() + it("can be created", function() + local Class = Object("Class") + assert_class(Class, nil, "Class") + end) + + describe("static", function() + describe("method", function() + describe(":new", function() + it("is called when creating instance", function() + local Class = Object("Class") + + spy.on(Class.static, "new") + Class() + assert.spy(Class.static.new).called_with(Class) + Class.static.new:revert() + + spy.on(Class.static, "new") + Class:new() + assert.spy(Class.static.new).called_with(Class) + Class.static.new:revert() + end) + + it("creates new instance", function() + local Class = Object("Class") + + local instance = Class:new() + assert_instance(instance, Class) + end) + end) + + describe(":extend", function() + it("creates subclass", function() + local Class = Object("Class") + + local SubClass = Class:extend("SubClass") + assert_class(SubClass, Class, "SubClass") + end) + end) + + describe(":is_subclass_of", function() + it("works", function() + local A, B, C = create_classes("A", { "B", "A" }, { "C", "B" }) + + for _, class in ipairs({ A, B, C }) do + h.eq(class.is_subclass_of, Object.is_subclass) + end + + h.eq(A:is_subclass_of(A), false) + h.eq(A:is_subclass_of(B), false) + h.eq(A:is_subclass_of(C), false) + + h.eq(B:is_subclass_of(A), true) + h.eq(B:is_subclass_of(B), false) + h.eq(B:is_subclass_of(C), false) + + h.eq(C:is_subclass_of(A), true) + h.eq(C:is_subclass_of(B), true) + h.eq(C:is_subclass_of(C), false) + end) + end) + end) + + local function define_static_say_level(A) + A.static.level = 1 + function A.static.say_level(class) + return "Level: " .. class.level + end + + h.eq(A.level, 1) + h.eq(A:say_level(), "Level: 1") + end + + it("can be defined for class", function() + local A = create_classes("A") + define_static_say_level(A) + end) + + it("is inherited by subclass", function() + local A, B = create_classes("A", { "B", "A" }) + + define_static_say_level(A) + + h.eq(B.level, 1) + h.eq(B:say_level(), "Level: 1") + + local C, D = create_classes({ "C", A }, { "D", B }) + + h.eq(C.level, 1) + h.eq(C:say_level(), "Level: 1") + + h.eq(D.level, 1) + h.eq(D:say_level(), "Level: 1") + end) + + it("can be redefined for subclass", function() + local A = create_classes("A") + define_static_say_level(A) + + local B = create_classes({ "B", A }) + + B.static.level = 2 + h.eq(B:say_level(), "Level: 2") + + function B.static.say_level(class) + return "LEVEL: " .. class.level + end + h.eq(B:say_level(), "LEVEL: 2") + + local C, D = create_classes({ "C", A }, { "D", B }) + + C.static.level = 2 + h.eq(C:say_level(), "Level: 2") + + D.static.level = 3 + h.eq(D:say_level(), "LEVEL: 3") + end) + + it("for subclass does not affect super", function() + local A = create_classes("A") + define_static_say_level(A) + + local B = create_classes({ "B", A }) + + B.static.level = 2 + function B.static.say_level(class) + return "LEVEL: " .. class.level + end + + h.eq(A:say_level(), "Level: 1") + + local C = create_classes({ "C", B }) + + function C.static.say_name(class) + return class.name + end + + h.eq(C:say_name(), "C") + + h.eq(type(C.say_name), "function") + h.eq(type(B.say_name), "nil") + h.eq(type(A.say_name), "nil") + end) + end) + + describe("instance", function() + it("can be created", function() + local A = create_classes("A") + + local a = A:new() + assert_instance(a, A) + end) + + describe("method", function() + describe(":is_instance_of", function() + it("works", function() + local A, B, C, D = create_classes("A", { "B", "A" }, { "C", "B" }, "D") + + local a, b, c, d = A:new(), B:new(), C:new(), D:new() + + for _, instance in ipairs({ a, b, c, d }) do + h.eq(instance.is_instance_of, Object.is_instance) + end + + h.eq(a:is_instance_of(A), true) + h.eq(a:is_instance_of(B), false) + h.eq(a:is_instance_of(C), false) + h.eq(a:is_instance_of(D), false) + + h.eq(b:is_instance_of(A), true) + h.eq(b:is_instance_of(B), true) + h.eq(b:is_instance_of(C), false) + h.eq(b:is_instance_of(D), false) + + h.eq(c:is_instance_of(A), true) + h.eq(c:is_instance_of(B), true) + h.eq(c:is_instance_of(C), true) + h.eq(c:is_instance_of(D), false) + + h.eq(d:is_instance_of(A), false) + h.eq(d:is_instance_of(B), false) + h.eq(d:is_instance_of(C), false) + h.eq(d:is_instance_of(D), true) + end) + end) + + it("can be defined", function() + local A = create_classes("A") + + function A:before_instance_creation() + return "before " .. self.class.name .. " instance" + end + + local a = A:new() + + function A:after_instance_creation() + return "after " .. self.class.name .. " instance" + end + + h.eq(a:before_instance_creation(), "before A instance") + h.eq(a:after_instance_creation(), "after A instance") + end) + + it("can be inherited", function() + local A, B = create_classes("A", { "B", "A" }) + + function A:say_class_name() + return self.class.name + end + + local a = A:new() + h.eq(a:say_class_name(), "A") + + local b = B:new() + h.eq(b:say_class_name(), "B") + + local C = create_classes({ "C", B }) + + local c = C:new() + h.eq(c:say_class_name(), "C") + end) + + it("can be redefined", function() + local A, B = create_classes("A", { "B", "A" }) + + function A:say_class_name() + return self.class.name + end + + local a = A:new() + h.eq(a:say_class_name(), "A") + + function B:say_class_name() + return string.lower(self.class.name) + end + + local b = B:new() + h.eq(b:say_class_name(), "b") + + local C = create_classes({ "C", B }) + + local c = C:new() + h.eq(c:say_class_name(), "c") + + function C:say_class_name() + return string.rep(self.class.name, 3) + end + + h.eq(c:say_class_name(), "CCC") + + C.say_class_name = nil + + h.eq(c:say_class_name(), "c") + + B.say_class_name = nil + + h.eq(c:say_class_name(), "C") + end) + end) + + describe("metamethod", function() + describe("__index", function() + it("can be set to table", function() + local A = create_classes("A") + + function A:upper(str) -- luacheck: no unused args + return string.upper(str) + end + + A.__index = { + upper = function(_, str) + return str + end, + lower = function(_, str) + return string.lower(str) + end, + } + + local a = A() + + h.eq(a:upper("y"), "Y") + + h.eq(a:lower("Y"), "y") + + A.__index = nil + + h.eq(type(a.lower), "nil") + end) + + it("can be set to function", function() + local A = create_classes("A") + + function A:upper(str) -- luacheck: no unused args + return string.upper(str) + end + + local index = { + upper = function(self, str) -- luacheck: no unused args + return str + end, + lower = function(self, str) -- luacheck: no unused args + return string.lower(str) + end, + } + + A.__index = function(self, key) -- luacheck: no unused args + return index[key] + end + + local a = A() + + h.eq(a:upper("y"), "Y") + + h.eq(a:lower("Y"), "y") + + A.__index = nil + + h.eq(type(a.lower), "nil") + end) + end) + + describe("__tostring", function() + it("can be redefined", function() + local A, B = create_classes("A", { "B", "A" }) + + local a = A() + + h.eq(tostring(a), "instance of class A") + + function A:__tostring() + return "class " .. self.class.name .. "'s child" + end + + h.eq(tostring(a), "class A's child") + + local b = B() + + h.eq(tostring(b), "class B's child") + + function B:__tostring() + return "child of " .. self.class.name + end + + h.eq(tostring(b), "child of B") + + B.__tostring = nil + + h.eq(tostring(b), "class B's child") + end) + end) + end) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/popup/border_spec.lua b/bundle/nui.nvim/tests/nui/popup/border_spec.lua new file mode 100644 index 000000000..5001b2b8a --- /dev/null +++ b/bundle/nui.nvim/tests/nui/popup/border_spec.lua @@ -0,0 +1,717 @@ +pcall(require, "luacov") + +local Popup = require("nui.popup") +local Text = require("nui.text") +local h = require("tests.helpers") + +local eq = h.eq + +describe("nui.popup", function() + local popup_options = {} + local popup + + before_each(function() + popup_options = { + ns_id = vim.api.nvim_create_namespace("NuiTest"), + position = "50%", + size = { + height = 2, + width = 8, + }, + } + end) + + after_each(function() + if popup then + popup:unmount() + popup = nil + end + end) + + describe("(#deprecated) border.highlight", function() + it("works for 'hl_group'", function() + local hl_group = "NuiPopupTest" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + highlight = hl_group, + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + eq(vim.api.nvim_win_get_option(popup.border.winid, "winhighlight"), "FloatBorder:" .. hl_group) + end) + + it("works for 'FloatBorder:hl_group'", function() + local hl_group = "NuiPopupTest" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + highlight = "FloatBorder:" .. hl_group, + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + eq(vim.api.nvim_win_get_option(popup.border.winid, "winhighlight"), "FloatBorder:" .. hl_group) + end) + end) + + describe("border.padding", function() + local function assert_padding(padding, target_popup) + local border_char_size = 1 + + local popup_win_config = vim.api.nvim_win_get_config(target_popup.winid) + eq(popup_win_config.win, target_popup.border.winid) + eq(popup_win_config.row[vim.val_idx], border_char_size + padding.top) + eq(popup_win_config.col[vim.val_idx], border_char_size + padding.left) + + local border_win_config = vim.api.nvim_win_get_config(target_popup.border.winid) + eq(border_win_config.width, popup_options.size.width + border_char_size * 2 + padding.right + padding.left) + eq(border_win_config.height, popup_options.size.height + border_char_size * 2 + padding.top + padding.bottom) + end + + it("supports list table", function() + local padding = { + top = 2, + right = 2, + bottom = 1, + left = 1, + } + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + padding = { padding.top, padding.right, padding.bottom, padding.left }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + assert_padding(padding, popup) + end) + + it("supports partial list table", function() + local padding = { + top = 2, + right = 1, + bottom = 2, + left = 1, + } + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + padding = { padding.top, padding.right }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + assert_padding(padding, popup) + end) + + it("supports map table", function() + local padding = { + top = 2, + right = 2, + bottom = 1, + left = 1, + } + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + padding = padding, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + assert_padding(padding, popup) + end) + end) + + describe("border.style", function() + describe("for complex border", function() + it("is normalized", function() + local index_name = { + "top_left", + "top", + "top_right", + "right", + "bottom_right", + "bottom", + "bottom_left", + "left", + } + + local char_map + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + char_map = popup.border._.char + eq(char_map, "none") + + popup_options.border.style = "shadow" + popup = Popup(popup_options) + char_map = popup.border._.char + eq(char_map, "shadow") + + popup_options.border.style = "rounded" + popup = Popup(popup_options) + char_map = popup.border._.char + for _, name in ipairs(index_name) do + eq(type(char_map[name]:content()), "string") + eq(char_map[name].extmark.hl_group, "FloatBorder") + end + + popup_options.border.style = h.popup.create_border_style_list() + popup_options.border.style[1] = { popup_options.border.style[1], "TopLeft" } + popup_options.border.style[2] = Text(popup_options.border.style[2]) + popup_options.border.style[3] = Text(popup_options.border.style[3], "TopRight") + popup_options.border.style[6] = { popup_options.border.style[6] } + popup = Popup(popup_options) + char_map = popup.border._.char + for _, name in ipairs(index_name) do + eq(type(char_map[name]:content()), "string") + if name == "top_left" then + eq(char_map[name].extmark.hl_group, "TopLeft") + elseif name == "top_right" then + eq(char_map[name].extmark.hl_group, "TopRight") + else + eq(char_map[name].extmark.hl_group, "FloatBorder") + end + end + + popup_options.border.style = h.popup.create_border_style_map() + popup_options.border.style.top_left = { popup_options.border.style.top_left, "TopLeft" } + popup_options.border.style.top = Text(popup_options.border.style.top) + popup_options.border.style.top_right = Text(popup_options.border.style.top_right, "TopRight") + popup_options.border.style.bottom = { popup_options.border.style.bottom } + popup = Popup(popup_options) + char_map = popup.border._.char + for _, name in ipairs(index_name) do + eq(type(char_map[name]:content()), "string") + if name == "top_left" then + eq(char_map[name].extmark.hl_group, "TopLeft") + elseif name == "top_right" then + eq(char_map[name].extmark.hl_group, "TopRight") + else + eq(char_map[name].extmark.hl_group, "FloatBorder") + end + end + end) + + it("supports string name", function() + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + h.assert_buf_lines(popup.border.bufnr, { + "╭────────╮", + "│ │", + "│ │", + "╰────────╯", + }) + end) + + it("supports list table", function() + local style = h.popup.create_border_style_list() + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + h.popup.assert_border_lines(popup_options, popup.border.bufnr) + end) + + it("supports partial list table", function() + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = { "-" }, + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + popup_options.border.style = { "-", "-", "-", "-", "-", "-", "-", "-" } + + h.popup.assert_border_lines(popup_options, popup.border.bufnr) + end) + + it("supports map table", function() + local style = h.popup.create_border_style_map() + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + h.popup.assert_border_lines(popup_options, popup.border.bufnr) + end) + + it("supports (char, hl_group) tuple in partial list table", function() + local hl_group = "NuiPopupTest" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = { { "-", hl_group } }, + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + popup_options.border.style = { "-", "-", "-", "-", "-", "-", "-", "-" } + + h.popup.assert_border_lines(popup_options, popup.border.bufnr) + h.popup.assert_border_highlight(popup_options, popup.border.bufnr, hl_group, true) + end) + + it("supports (char, hl_group) tuple in map table", function() + local hl_group = "NuiPopupTest" + local style = h.popup.create_border_style_map_with_tuple(hl_group) + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + h.popup.assert_border_lines(popup_options, popup.border.bufnr) + h.popup.assert_border_highlight(popup_options, popup.border.bufnr, hl_group) + end) + + it("supports nui.text in map table", function() + local hl_group = "NuiPopupTest" + local style = h.popup.create_border_style_map_with_nui_text(hl_group) + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + padding = { 0 }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + h.popup.assert_border_lines(popup_options, popup.border.bufnr) + h.popup.assert_border_highlight(popup_options, popup.border.bufnr, hl_group) + end) + end) + + describe("for simple border", function() + it("supports nui.text as char", function() + local hl_group = "NuiPopupTest" + + local style = h.popup.create_border_style_list() + style[2] = Text(style[2], hl_group) + style[6] = Text(style[6]) + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + local win_config = vim.api.nvim_win_get_config(popup.winid) + + eq(win_config.border[2], { style[2]:content(), hl_group }) + eq(win_config.border[6], style[6]:content()) + end) + + it("supports (char, hl_group) tuple as char", function() + local hl_group = "NuiPopupTest" + + local style = h.popup.create_border_style_list() + style[2] = { style[2], hl_group } + style[6] = { style[6] } + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + local win_config = vim.api.nvim_win_get_config(popup.winid) + + eq(win_config.border[2], { style[2][1], style[2][2] }) + eq(win_config.border[6], style[6][1]) + end) + + it("throws if map table missing keys", function() + local style = h.popup.create_border_style_map() + style["top"] = nil + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + }, + }) + + local ok, err = pcall(Popup, popup_options) + eq(ok, false) + eq(type(string.match(err, "missing named border: top")), "string") + end) + end) + end) + + describe("border.text", function() + it("throws error if borderless", function() + local text = "popup" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "none", + text = { + top = text, + }, + }, + }) + + local ok, err = pcall(Popup, popup_options) + eq(ok, false) + eq(type(string.match(err, "text not supported for style:")), "string") + end) + + it("throws error if invalid border style", function() + local text = "popup" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "__invalid__", + text = { + top = text, + }, + }, + }) + + local ok, err = pcall(Popup, popup_options) + eq(ok, false) + eq(type(string.match(err, "invalid border style name")), "string") + end) + + it("supports simple text", function() + local text = "popup" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "single", + text = { + top = text, + }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + h.assert_buf_lines(popup.border.bufnr, { + "┌─popup──┐", + }, 1, 1) + h.assert_highlight(popup.border.bufnr, popup_options.ns_id, 1, text, "FloatTitle") + end) + + it("supports nui.text", function() + local text = "popup" + local hl_group = "NuiPopupTest" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "single", + text = { + top = Text(text), + bottom = Text(text, hl_group), + }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + h.assert_buf_lines(popup.border.bufnr, { + "┌─popup──┐", + }, 1, 1) + h.assert_highlight(popup.border.bufnr, popup_options.ns_id, 1, text, "FloatTitle") + + h.assert_buf_lines(popup.border.bufnr, { + "└─popup──┘", + }, 4, 4) + h.assert_highlight(popup.border.bufnr, popup_options.ns_id, 4, text, hl_group) + end) + end) + + describe("method :mount", function() + it("sets winhighlight correctly", function() + local hl_group = "NuiPopupTest" + local winhighlight = "Normal:Normal,FloatBorder:" .. hl_group + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + text = { + top = "text", + }, + }, + win_options = { + winhighlight = winhighlight, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + eq(vim.api.nvim_win_get_option(popup.border.winid, "winhighlight"), winhighlight) + end) + + it("does nothing if popup mounted", function() + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + text = { + top = "text", + }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + local bufnr, winid = popup.border.bufnr, popup.border.winid + eq(type(bufnr), "number") + eq(type(winid), "number") + + popup.border:mount() + + eq(bufnr, popup.border.bufnr) + eq(winid, popup.border.winid) + end) + end) + + describe("method :unmount", function() + it("does nothing if popup not mounted", function() + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + text = { + top = "text", + }, + }, + }) + + popup = Popup(popup_options) + + eq(type(popup.border.bufnr), "nil") + eq(type(popup.border.winid), "nil") + + popup.border:unmount() + + eq(type(popup.border.bufnr), "nil") + eq(type(popup.border.winid), "nil") + end) + end) + + describe("method :set_text", function() + it("works", function() + local text_top, text_bottom = "top", "bot" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + text = { + top = text_top, + top_align = "left", + }, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + h.assert_buf_lines(popup.border.bufnr, { + "╭top─────╮", + "│ │", + "│ │", + "╰────────╯", + }) + + popup.border:set_text("top", text_top, "center") + + h.assert_buf_lines(popup.border.bufnr, { + "╭──top───╮", + "│ │", + "│ │", + "╰────────╯", + }) + + popup.border:set_text("top", text_top, "right") + + h.assert_buf_lines(popup.border.bufnr, { + "╭─────top╮", + "│ │", + "│ │", + "╰────────╯", + }) + + local hl_group = "NuiPopupTest" + + popup.border:set_text("bottom", Text(text_bottom, hl_group)) + + h.assert_buf_lines(popup.border.bufnr, { + "╭─────top╮", + "│ │", + "│ │", + "╰──bot───╯", + }) + + h.assert_highlight(popup.border.bufnr, popup_options.ns_id, 4, text_bottom, hl_group) + end) + + it("does nothing for simple border", function() + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = "rounded", + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + eq(type(popup.border.bufnr), "nil") + + popup.border:set_text("top", "text") + + eq(type(popup.border.bufnr), "nil") + end) + end) + + describe("method :set_highlight", function() + it("works for simple border", function() + local style = h.popup.create_border_style_map() + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + }, + win_options = { + winhighlight = "Normal:Normal,FloatBorder:Normal", + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + eq(popup.border.winid, nil) + + local hl_group = "NuiPopupTest" + + popup.border:set_highlight(hl_group) + + eq(vim.api.nvim_win_get_option(popup.winid, "winhighlight"), "FloatBorder:" .. hl_group .. ",Normal:Normal") + end) + + it("works for complex border", function() + local style = h.popup.create_border_style_map() + + local hl_group = "NuiPopupTest" + + popup_options = vim.tbl_deep_extend("force", popup_options, { + border = { + style = style, + padding = { 0 }, + }, + win_options = { + winhighlight = "Normal:Normal,FloatBorder:" .. hl_group, + }, + }) + + popup = Popup(popup_options) + + popup:mount() + + eq(vim.api.nvim_win_get_option(popup.winid, "winhighlight"), "Normal:Normal,FloatBorder:" .. hl_group) + eq(vim.api.nvim_win_get_option(popup.border.winid, "winhighlight"), "Normal:Normal,FloatBorder:" .. hl_group) + + local hl_group_override = "NuiPopupTestOverride" + + popup.border:set_highlight(hl_group_override) + + eq( + vim.api.nvim_win_get_option(popup.winid, "winhighlight"), + "FloatBorder:" .. hl_group_override .. ",Normal:Normal" + ) + eq( + vim.api.nvim_win_get_option(popup.border.winid, "winhighlight"), + "FloatBorder:" .. hl_group_override .. ",Normal:Normal" + ) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/popup/init_spec.lua b/bundle/nui.nvim/tests/nui/popup/init_spec.lua new file mode 100644 index 000000000..5875ba548 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/popup/init_spec.lua @@ -0,0 +1,1130 @@ +pcall(require, "luacov") + +local Popup = require("nui.popup") +local event = require("nui.utils.autocmd").event +local h = require("tests.helpers") +local spy = require("luassert.spy") + +local eq, feedkeys = h.eq, h.feedkeys + +local function percent(number, percentage) + return math.floor(number * percentage / 100) +end + +describe("nui.popup", function() + local popup + + after_each(function() + if popup then + popup:unmount() + popup = nil + end + end) + + it("supports o.bufnr (unmanaed buffer)", function() + local bufnr = vim.api.nvim_create_buf(false, true) + + local lines = { + "a", + "b", + "c", + } + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + popup = Popup({ + bufnr = bufnr, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + h.assert_buf_lines(bufnr, lines) + eq(popup.bufnr, bufnr) + popup:mount() + h.assert_buf_lines(bufnr, lines) + popup:unmount() + eq(popup.bufnr, bufnr) + h.assert_buf_lines(bufnr, lines) + end) + + it("accepts number as o.ns_id", function() + local ns = "NuiPopupTest" + local ns_id = vim.api.nvim_create_namespace(ns) + + popup = Popup({ + ns_id = ns_id, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + eq(popup.ns_id, ns_id) + end) + + it("accepts string as o.ns_id", function() + local ns = "NuiPopupTest" + + popup = Popup({ + ns_id = ns, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + eq(popup.ns_id, vim.api.nvim_create_namespace(ns)) + end) + + it("uses fallback ns_id if o.ns_id=nil", function() + popup = Popup({ + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + eq(type(popup.ns_id), "number") + eq(popup.ns_id > 0, true) + end) + + h.describe_flipping_feature("lua_keymap", "method :map", function() + it("works before :mount", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:map("n", "l", function() + callback() + end) + + popup:mount() + + feedkeys("l", "x") + + assert.spy(callback).called() + end) + + it("works after :mount", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", "l", function() + callback() + end) + + feedkeys("l", "x") + + assert.spy(callback).called() + end) + + it("supports lhs table", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", { "k", "l" }, "o42") + + feedkeys("k", "x") + feedkeys("l", "x") + + h.assert_buf_lines(popup.bufnr, { + "", + "42", + "42", + }) + end) + + it("supports rhs function", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", "l", function() + callback() + end) + + feedkeys("l", "x") + + assert.spy(callback).called() + end) + + it("supports rhs string", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", "l", "o42") + + feedkeys("l", "x") + + h.assert_buf_lines(popup.bufnr, { + "", + "42", + }) + end) + + it("supports o.remap=true", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", "k", "o42") + popup:map("n", "l", "k", { remap = true }) + + feedkeys("k", "x") + feedkeys("l", "x") + + h.assert_buf_lines(popup.bufnr, { + "", + "42", + "42", + }) + end) + + it("supports o.remap=false", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", "k", "o42") + popup:map("n", "l", "k", { remap = false }) + + feedkeys("k", "x") + feedkeys("l", "x") + + h.assert_buf_lines(popup.bufnr, { + "", + "42", + }) + end) + + it("throws if .bufnr is nil", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup.bufnr = nil + + local ok, result = pcall(function() + popup:map("n", "l", function() end) + end) + + eq(ok, false) + eq(type(string.match(result, "buffer not found")), "string") + end) + end) + + h.describe_flipping_feature("lua_keymap", "method :unmap", function() + it("works before :mount", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:map("n", "l", function() + callback() + end) + + popup:unmap("n", "l") + + popup:mount() + + feedkeys("l", "x") + + assert.spy(callback).not_called() + end) + + it("works after :mount", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", "l", function() + callback() + end) + + popup:unmap("n", "l") + + feedkeys("l", "x") + + assert.spy(callback).not_called() + end) + + it("supports lhs string", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", "l", "o42") + + popup:unmap("n", "l") + + feedkeys("l", "x") + + h.assert_buf_lines(popup.bufnr, { + "", + }) + end) + + it("supports lhs table", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:map("n", "k", "o42") + popup:map("n", "l", "o42") + + popup:unmap("n", { "k", "l" }) + + feedkeys("k", "x") + feedkeys("l", "x") + + h.assert_buf_lines(popup.bufnr, { + "", + }) + end) + + it("throws if .bufnr is nil", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup.bufnr = nil + + local ok, result = pcall(function() + popup:unmap("n", "l") + end) + + eq(ok, false) + eq(type(string.match(result, "buffer not found")), "string") + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :on", function() + it("works before :mount", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:on(event.InsertEnter, function() + callback() + end) + + popup:mount() + + feedkeys("i", "x") + feedkeys("", "x") + + assert.spy(callback).called() + end) + + it("works after :mount", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:on(event.InsertEnter, function() + callback() + end) + + feedkeys("i", "x") + feedkeys("", "x") + + assert.spy(callback).called() + end) + + it("throws if .bufnr is nil", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup.bufnr = nil + + local ok, result = pcall(function() + popup:on(event.InsertEnter, function() end) + end) + + eq(ok, false) + eq(type(string.match(result, "buffer not found")), "string") + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :off", function() + it("works before :mount", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:on(event.InsertEnter, function() + callback() + end) + + popup:off(event.InsertEnter) + + popup:mount() + + feedkeys("i", "x") + feedkeys("", "x") + + assert.spy(callback).not_called() + end) + + it("works after :mount", function() + local callback = spy.new(function() end) + + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup:mount() + + popup:on(event.InsertEnter, function() + callback() + end) + + popup:off(event.InsertEnter) + + feedkeys("i", "x") + feedkeys("", "x") + + assert.spy(callback).not_called() + end) + + it("throws if .bufnr is nil", function() + popup = Popup({ + enter = true, + position = "50%", + size = { + height = "60%", + width = "80%", + }, + }) + + popup.bufnr = nil + + local ok, result = pcall(function() + popup:off() + end) + + eq(ok, false) + eq(type(string.match(result, "buffer not found")), "string") + end) + end) + + describe("method :update_layout", function() + local function assert_size(size, border_size) + if border_size and type(border_size) ~= "table" then + border_size = { + width = size.width + 2, + height = size.height + 2, + } + end + + local win_config = vim.api.nvim_win_get_config(popup.winid) + eq(win_config.width, size.width) + eq(win_config.height, size.height) + + if popup.border.winid then + local border_win_config = vim.api.nvim_win_get_config(popup.border.winid) + eq(border_win_config.width, border_size.width) + eq(border_win_config.height, border_size.height) + end + end + + local function assert_position(position, container_winid) + container_winid = container_winid or vim.api.nvim_get_current_win() + + local win_config = vim.api.nvim_win_get_config(popup.winid) + eq(win_config.win, popup.border.winid or container_winid) + + local row, col = win_config.row[vim.val_idx], win_config.col[vim.val_idx] + + if popup.border.winid then + eq(row, 1) + eq(col, 1) + + local border_win_config = vim.api.nvim_win_get_config(popup.border.winid) + local border_row, border_col = border_win_config.row[vim.val_idx], border_win_config.col[vim.val_idx] + local border_width, border_height = border_win_config.width, border_win_config.height + + local delta_width = border_width - win_config.width + local delta_height = border_height - win_config.height + + eq(border_row + math.floor(delta_height / 2 + 0.5), position.row) + eq(border_col + math.floor(delta_width / 2 + 0.5), position.col) + else + eq(row, position.row) + eq(col, position.col) + end + end + + it("can change size (w/ simple border)", function() + local size = { + width = 2, + height = 1, + } + + popup = Popup({ + position = "50%", + size = size, + }) + + popup:mount() + + eq(type(popup.border.winid), "nil") + + assert_size(size) + + local new_size = { + width = size.width + 2, + height = size.height + 2, + } + + popup:update_layout({ size = new_size }) + + assert_size(new_size) + end) + + it("can change size (w/ complex border)", function() + local hl_group = "NuiPopupTest" + local style = h.popup.create_border_style_map_with_tuple(hl_group) + + local size = { + width = 2, + height = 1, + } + + popup = Popup({ + ns_id = vim.api.nvim_create_namespace("NuiTest"), + border = { + style = style, + padding = { 0 }, + }, + position = "50%", + size = size, + }) + + popup:mount() + + eq(type(popup.border.winid), "number") + + assert_size(size, true) + h.popup.assert_border_lines({ + size = size, + border = { style = style }, + }, popup.border.bufnr) + h.popup.assert_border_highlight({ + size = size, + ns_id = popup.ns_id, + }, popup.border.bufnr, hl_group) + + local new_size = { + width = size.width + 2, + height = size.height + 2, + } + + popup:update_layout({ size = new_size }) + + assert_size(new_size, true) + h.popup.assert_border_lines({ + size = new_size, + border = { style = style }, + }, popup.border.bufnr) + h.popup.assert_border_highlight({ + size = new_size, + ns_id = popup.ns_id, + }, popup.border.bufnr, hl_group) + end) + + it("can change position (w/ simple border)", function() + local position = { + row = 0, + col = 0, + } + + popup = Popup({ + position = position, + size = { + width = 4, + height = 2, + }, + }) + + popup:mount() + + eq(type(popup.border.winid), "nil") + + assert_position(position) + + local new_position = { + row = position.row + 2, + col = position.col + 2, + } + + popup:update_layout({ position = new_position }) + + assert_position(new_position) + end) + + it("can change position (w/ complex border)", function() + local hl_group = "NuiPopupTest" + local style = h.popup.create_border_style_map_with_tuple(hl_group) + + local position = { + row = 0, + col = 0, + } + + popup = Popup({ + ns_id = vim.api.nvim_create_namespace("NuiTest"), + border = { + style = style, + padding = { 0 }, + }, + position = position, + size = { + width = 4, + height = 2, + }, + }) + + popup:mount() + + eq(type(popup.border.winid), "number") + + assert_position(position) + + local new_position = { + row = position.row + 2, + col = position.col + 2, + } + + popup:update_layout({ position = new_position }) + + assert_position(new_position) + end) + + it("refreshes layout if container size changes", function() + local container_size = { + width = 20, + height = 10, + } + + local container_popup = Popup({ + position = 0, + size = container_size, + }) + + container_popup:mount() + + popup = Popup({ + relative = { + type = "win", + winid = container_popup.winid, + }, + position = "20%", + size = "50%", + }) + + popup:mount() + + assert_size({ + width = percent(container_size.width, 50), + height = percent(container_size.height, 50), + }) + + assert_position({ + row = percent(container_size.height - percent(container_size.height, 50), 20), + col = percent(container_size.width - percent(container_size.width, 50), 20), + }, container_popup.winid) + + container_size = { + width = 16, + height = 8, + } + + container_popup:update_layout({ + size = container_size, + }) + + popup:update_layout() + + assert_size({ + width = percent(container_size.width, 50), + height = percent(container_size.height, 50), + }) + + assert_position({ + row = percent(container_size.height - percent(container_size.height, 50), 20), + col = percent(container_size.width - percent(container_size.width, 50), 20), + }, container_popup.winid) + end) + + it("throws if missing config 'relative'", function() + popup = Popup({}) + + local ok, result = pcall(function() + popup:update_layout({}) + end) + + eq(ok, false) + eq(type(string.match(result, "missing layout config: relative")), "string") + end) + + it("throws if missing config 'size'", function() + popup = Popup({}) + + local ok, result = pcall(function() + popup:update_layout({ + relative = "win", + }) + end) + + eq(ok, false) + eq(type(string.match(result, "missing layout config: size")), "string") + end) + + it("throws if missing config 'position'", function() + popup = Popup({}) + + local ok, result = pcall(function() + popup:update_layout({ + relative = "win", + size = "50%", + }) + end) + + eq(ok, false) + eq(type(string.match(result, "missing layout config: position")), "string") + end) + end) + + describe("method :mount", function() + it("throws if layout is not ready", function() + popup = Popup({}) + + local ok, result = pcall(function() + popup:mount() + end) + + eq(ok, false) + eq(type(string.match(result, "layout is not ready")), "string") + end) + + it("is idempotent", function() + popup = Popup({ + position = 0, + size = 10, + }) + + local border_mount = spy.on(popup.border, "mount") + + popup:mount() + + local bufnr, winid = popup.bufnr, popup.winid + + eq(type(bufnr), "number") + eq(type(winid), "number") + assert.spy(border_mount).was_called(1) + + popup:mount() + + eq(bufnr, popup.bufnr) + eq(winid, popup.winid) + assert.spy(border_mount).was_called(1) + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :unmount", function() + it("is called when quitted", function() + popup = Popup({ + position = 0, + size = 10, + }) + + local popup_unmount = spy.on(popup, "unmount") + + popup:mount() + + vim.api.nvim_buf_call(popup.bufnr, function() + vim.cmd([[quit]]) + end) + + vim.wait(100, function() + return not popup._.mounted + end, 10) + + assert.spy(popup_unmount).was_called() + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :hide", function() + it("works", function() + popup = Popup({ + position = 0, + size = 10, + }) + + popup:mount() + + vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, { + "42", + }) + + eq(type(popup.bufnr), "number") + eq(type(popup.winid), "number") + + popup:hide() + + eq(type(popup.bufnr), "number") + eq(type(popup.winid), "nil") + + h.assert_buf_lines(popup.bufnr, { + "42", + }) + end) + + it("is idempotent", function() + popup = Popup({ + position = 0, + size = 10, + }) + + popup:mount() + + local prev_winids = vim.api.nvim_list_wins() + + popup:hide() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids, #curr_winids + 1) + + popup:hide() + + eq(#curr_winids, #vim.api.nvim_list_wins()) + end) + + it("does nothing if not mounted", function() + popup = Popup({ + position = 0, + size = 10, + }) + + local prev_winids = vim.api.nvim_list_wins() + + popup:hide() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids, #curr_winids) + end) + + it("is called when window is closed", function() + popup = Popup({ + position = 0, + size = 10, + }) + + local popup_hide = spy.on(popup, "hide") + + popup:mount() + + vim.api.nvim_buf_call(popup.bufnr, function() + vim.cmd([[:bdelete]]) + end) + + assert.spy(popup_hide).was_called() + end) + + it("is not called when other popup using same buffer is hidden", function() + popup = Popup({ + position = 0, + size = 10, + }) + + local another_popup = Popup({ + bufnr = popup.bufnr, + position = 11, + size = 5, + }) + + local popup_hide = spy.on(popup, "hide") + + popup:mount() + + another_popup:mount() + another_popup:hide() + + assert.spy(popup_hide).was_not_called() + + another_popup:unmount() + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :show", function() + it("works", function() + popup = Popup({ + position = 0, + size = 10, + }) + + popup:mount() + + vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, { + "42", + }) + + local bufnr, winid = popup.bufnr, popup.winid + eq(type(bufnr), "number") + eq(type(winid), "number") + + popup:hide() + popup:show() + + eq(bufnr, popup.bufnr) + eq(type(popup.winid), "number") + eq(winid ~= popup.winid, true) + + h.assert_buf_lines(popup.bufnr, { + "42", + }) + end) + + it("is idempotent", function() + popup = Popup({ + position = 0, + size = 10, + }) + + popup:mount() + + popup:hide() + + local prev_winids = vim.api.nvim_list_wins() + + popup:show() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids + 1, #curr_winids) + + popup:show() + + eq(#curr_winids, #vim.api.nvim_list_wins()) + end) + + it("does nothing if not mounted", function() + popup = Popup({ + position = 0, + size = 10, + }) + + local prev_winids = vim.api.nvim_list_wins() + + popup:show() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids, #curr_winids) + end) + + it("does nothing if not hidden", function() + popup = Popup({ + position = 0, + size = 10, + }) + + popup:mount() + + local prev_winids = vim.api.nvim_list_wins() + + popup:show() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids, #curr_winids) + end) + + it("can show popups using the same buffer", function() + popup = Popup({ + position = 0, + size = 10, + }) + + vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, { + "42", + }) + + local another_popup = Popup({ + bufnr = popup.bufnr, + position = 11, + size = 5, + }) + + popup:mount() + another_popup:mount() + + local bufnr, winid = popup.bufnr, popup.winid + eq(type(bufnr), "number") + eq(type(winid), "number") + + local another_bufnr, another_winid = another_popup.bufnr, another_popup.winid + eq(type(another_bufnr), "number") + eq(type(another_winid), "number") + + eq(bufnr, another_bufnr) + + popup:hide() + another_popup:hide() + + popup:show() + another_popup:show() + + eq(bufnr, popup.bufnr) + eq(type(popup.winid), "number") + eq(winid ~= popup.winid, true) + + eq(another_bufnr, another_popup.bufnr) + eq(type(another_popup.winid), "number") + eq(another_winid ~= another_popup.winid, true) + + h.assert_buf_lines(bufnr, { + "42", + }) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/split/init_spec.lua b/bundle/nui.nvim/tests/nui/split/init_spec.lua new file mode 100644 index 000000000..8bde9caf2 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/split/init_spec.lua @@ -0,0 +1,971 @@ +pcall(require, "luacov") + +local Split = require("nui.split") +local event = require("nui.utils.autocmd").event +local h = require("tests.helpers") +local spy = require("luassert.spy") + +local eq, feedkeys = h.eq, h.feedkeys + +local function percent(number, percentage) + return math.floor(number * percentage / 100) +end + +describe("nui.split", function() + local split + + before_each(function() + vim.o.winwidth = 10 + end) + + after_each(function() + split:unmount() + end) + + describe("o.enter", function() + it("if true, sets the split as current window", function() + split = Split({ + enter = true, + size = 10, + position = "bottom", + }) + + local winid = vim.api.nvim_get_current_win() + + split:mount() + + h.eq(winid ~= split.winid, true) + h.eq(split.winid, vim.api.nvim_get_current_win()) + end) + + it("if false, does not set the split as current window", function() + split = Split({ + enter = false, + size = 10, + position = "bottom", + }) + + local winid = vim.api.nvim_get_current_win() + + split:mount() + + h.eq(winid ~= split.winid, true) + h.eq(winid, vim.api.nvim_get_current_win()) + end) + + it("is true by default", function() + split = Split({ + size = 10, + position = "bottom", + }) + + local winid = vim.api.nvim_get_current_win() + + split:mount() + + h.eq(winid ~= split.winid, true) + h.eq(split.winid, vim.api.nvim_get_current_win()) + end) + end) + + describe("o.size", function() + for position, dimension in pairs({ top = "height", right = "width", bottom = "height", left = "width" }) do + it(string.format("is set as %s if o.position=%s", dimension, position), function() + local size = 20 + + split = Split({ + size = size, + position = position, + }) + + split:mount() + + local nvim_method = string.format("nvim_win_get_%s", dimension) + + eq(vim.api[nvim_method](split.winid), size) + end) + end + + it("supports table (.width)", function() + local size = 10 + + split = Split({ + size = { width = size }, + position = "left", + }) + + split:mount() + + eq(vim.api.nvim_win_get_width(split.winid), size) + end) + + it("supports table (.height)", function() + local size = 10 + + split = Split({ + size = { height = size }, + position = "top", + }) + + split:mount() + + eq(vim.api.nvim_win_get_height(split.winid), size) + end) + + it("is optional", function() + split = Split({ + position = "bottom", + }) + + split:mount() + + eq(type(vim.api.nvim_win_get_height(split.winid)), "number") + end) + + it("works with relative='editor'", function() + vim.api.nvim_set_option("showtabline", 2) + vim.api.nvim_set_option("cmdheight", 2) + + split = Split({ + size = "50%", + position = "bottom", + relative = "editor", + }) + + split:mount() + + eq(vim.api.nvim_win_get_height(split.winid), math.floor((vim.o.lines - 4) / 2)) + + vim.api.nvim_set_option("cmdheight", 1) + vim.api.nvim_set_option("showtabline", 1) + end) + end) + + describe("o.relative", function() + it("supports 'editor'", function() + local left_half_split = Split({ + size = "50%", + position = "left", + }) + + left_half_split:mount() + + split = Split({ + size = 20, + position = "bottom", + relative = "editor", + }) + + split:mount() + + eq(vim.api.nvim_win_get_width(split.winid), vim.o.columns) + + left_half_split:unmount() + end) + + it("supports 'win'", function() + local left_half_split = Split({ + size = "50%", + position = "left", + }) + + left_half_split:mount() + + split = Split({ + size = 20, + position = "bottom", + relative = "win", + }) + + split:mount() + + eq(vim.api.nvim_win_get_width(split.winid), vim.o.columns / 2) + + left_half_split:unmount() + end) + + it("supports specific window", function() + local winid = vim.api.nvim_get_current_win() + + local left_half_split = Split({ + enter = false, + size = "30%", + position = "left", + }) + + left_half_split:mount() + + eq(winid, vim.api.nvim_get_current_win()) + + eq(vim.api.nvim_win_get_width(left_half_split.winid), vim.o.columns * 30 / 100) + + split = Split({ + enter = false, + size = 10, + position = "bottom", + relative = { + type = "win", + winid = left_half_split.winid, + }, + }) + + split:mount() + + eq(winid, vim.api.nvim_get_current_win()) + + eq(vim.api.nvim_win_get_width(split.winid), vim.o.columns * 30 / 100) + + left_half_split:unmount() + end) + end) + + describe("method :mount", function() + it("opens win if not mounted", function() + split = Split({ + size = 20, + position = "bottom", + }) + + local prev_winids = vim.api.nvim_list_wins() + + split:mount() + + local new_winids = vim.tbl_filter(function(winid) + return not vim.tbl_contains(prev_winids, winid) + end, vim.api.nvim_list_wins()) + + eq(#new_winids, 1) + end) + + it("does nothing if already mounted", function() + split = Split({ + size = 20, + position = "bottom", + }) + + split:mount() + + local prev_winids = vim.api.nvim_list_wins() + + split:mount() + + local new_winids = vim.tbl_filter(function(winid) + return not vim.tbl_contains(prev_winids, winid) + end, vim.api.nvim_list_wins()) + + eq(#new_winids, 0) + end) + + it("sets buffer after creating window", function() + local ok, winid = false, nil + + split = Split({ + size = 20, + position = "bottom", + }) + + split:on(event.BufWinEnter, function() + ok, winid = true, split.winid + end) + + split:mount() + + eq(type(split.winid), "number") + + eq(ok, true) + eq(winid, split.winid) + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :unmount", function() + it("closes win if mounted", function() + split = Split({ + size = 20, + position = "bottom", + }) + + split:mount() + + local split_winid = split.winid + + local prev_winids = vim.api.nvim_list_wins() + + split:unmount() + + local curr_winids = vim.api.nvim_list_wins() + local closed_winids = vim.tbl_filter(function(winid) + return not vim.tbl_contains(curr_winids, winid) + end, prev_winids) + + eq(#closed_winids, 1) + eq(closed_winids[1], split_winid) + end) + + it("does nothing if already unmounted", function() + split = Split({ + size = 20, + position = "bottom", + }) + + local prev_winids = vim.api.nvim_list_wins() + + split:unmount() + + local curr_winids = vim.api.nvim_list_wins() + local closed_winids = vim.tbl_filter(function(winid) + return not vim.tbl_contains(curr_winids, winid) + end, prev_winids) + + eq(#closed_winids, 0) + end) + + it("is called when quitted", function() + split = Split({ + size = 10, + position = "bottom", + }) + + local split_unmount = spy.on(split, "unmount") + + split:mount() + + vim.api.nvim_buf_call(split.bufnr, function() + vim.cmd([[quit]]) + end) + + vim.wait(100, function() + return not split._.mounted + end, 10) + + assert.spy(split_unmount).was_called() + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :hide", function() + it("works", function() + local winid = vim.api.nvim_get_current_win() + + local win_height = vim.api.nvim_win_get_height(winid) + + split = Split({ + size = 20, + position = "bottom", + }) + + split:mount() + + vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { + "42", + }) + + eq(vim.api.nvim_win_get_height(winid) < win_height, true) + + split:hide() + + h.assert_buf_lines(split.bufnr, { + "42", + }) + + eq(vim.api.nvim_win_get_height(winid) == win_height, true) + end) + + it("is idempotent", function() + split = Split({ + size = 20, + position = "bottom", + }) + + split:mount() + + local prev_winids = vim.api.nvim_list_wins() + + split:hide() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids, #curr_winids + 1) + + split:hide() + + eq(#curr_winids, #vim.api.nvim_list_wins()) + end) + + it("does nothing if not mounted", function() + split = Split({ + size = 20, + position = "bottom", + }) + + local prev_winids = vim.api.nvim_list_wins() + + split:hide() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids, #curr_winids) + end) + + it("is called when window is closed", function() + split = Split({ + size = 20, + position = "bottom", + }) + + local split_hide = spy.on(split, "hide") + + split:mount() + + vim.api.nvim_buf_call(split.bufnr, function() + vim.cmd([[:bdelete]]) + end) + + assert.spy(split_hide).was_called() + end) + end) + + describe("method :show", function() + it("works", function() + local winid = vim.api.nvim_get_current_win() + + split = Split({ + size = 20, + position = "bottom", + }) + + split:mount() + + vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { + "42", + }) + + local win_height = vim.api.nvim_win_get_height(winid) + + split:hide() + split:show() + + h.assert_buf_lines(split.bufnr, { + "42", + }) + + eq(vim.api.nvim_win_get_height(winid) == win_height, true) + end) + + it("is idempotent", function() + split = Split({ + size = 20, + position = "bottom", + }) + + split:mount() + + split:hide() + + local prev_winids = vim.api.nvim_list_wins() + + split:show() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids + 1, #curr_winids) + + split:show() + + eq(#curr_winids, #vim.api.nvim_list_wins()) + end) + + it("does nothing if not mounted", function() + split = Split({ + size = 20, + position = "bottom", + }) + + local prev_winids = vim.api.nvim_list_wins() + + split:show() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids, #curr_winids) + end) + + it("does nothing if not hidden", function() + split = Split({ + size = 20, + position = "bottom", + }) + + split:mount() + + local prev_winids = vim.api.nvim_list_wins() + + split:show() + + local curr_winids = vim.api.nvim_list_wins() + + eq(#prev_winids, #curr_winids) + end) + end) + + describe("method :update_layout", function() + it("can change size", function() + split = Split({ positon = "bottom", size = 10 }) + + split:mount() + + eq(vim.api.nvim_win_get_height(split.winid), 10) + + split:update_layout({ size = 20 }) + + eq(vim.api.nvim_win_get_height(split.winid), 20) + end) + + it("can change position", function() + local winid = vim.api.nvim_get_current_win() + + split = Split({ position = "bottom", size = 10 }) + + split:mount() + + eq(vim.fn.winlayout(), { + "col", + { + { "leaf", winid }, + { "leaf", split.winid }, + }, + }) + + split:update_layout({ position = "right" }) + + eq(vim.fn.winlayout(), { + "row", + { + { "leaf", winid }, + { "leaf", split.winid }, + }, + }) + end) + + it("can change position and size", function() + local winid = vim.api.nvim_get_current_win() + + split = Split({ position = "top", size = 10 }) + + split:mount() + + eq(vim.api.nvim_win_get_height(split.winid), 10) + eq(vim.fn.winlayout(), { + "col", + { + { "leaf", split.winid }, + { "leaf", winid }, + }, + }) + + split:update_layout({ position = "left", size = 20 }) + + eq(vim.api.nvim_win_get_width(split.winid), 20) + eq(vim.fn.winlayout(), { + "row", + { + { "leaf", split.winid }, + { "leaf", winid }, + }, + }) + end) + + it("can change relative", function() + local winid_one = vim.api.nvim_get_current_win() + local split_two = Split({ position = "right", size = 10 }) + split_two:mount() + + split = Split({ relative = "win", position = "top", size = 10 }) + + split:mount() + + eq(vim.api.nvim_win_get_height(split.winid), 10) + eq(vim.fn.winlayout(), { + "row", + { + { "leaf", winid_one }, + { + "col", + { + { "leaf", split.winid }, + { "leaf", split_two.winid }, + }, + }, + }, + }) + + split:update_layout({ position = "bottom", relative = "editor", size = "50%" }) + + eq(vim.api.nvim_win_get_height(split.winid), percent(vim.o.lines - 1, 50)) + eq(vim.fn.winlayout(), { + "col", + { + { + "row", + { + { "leaf", winid_one }, + { "leaf", split_two.winid }, + }, + }, + { "leaf", split.winid }, + }, + }) + + split_two:unmount() + end) + end) + + h.describe_flipping_feature("lua_keymap", "method :map", function() + it("works before :mount", function() + local callback = spy.new(function() end) + + split = Split({ + size = 20, + }) + + split:map("n", "l", function() + callback() + end) + + split:mount() + + feedkeys("l", "x") + + assert.spy(callback).called() + end) + + it("works after :mount", function() + local callback = spy.new(function() end) + + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", "l", function() + callback() + end) + + feedkeys("l", "x") + + assert.spy(callback).called() + end) + + it("supports lhs table", function() + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", { "k", "l" }, "o42") + + feedkeys("k", "x") + feedkeys("l", "x") + + h.assert_buf_lines(split.bufnr, { + "", + "42", + "42", + }) + end) + + it("supports rhs function", function() + local callback = spy.new(function() end) + + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", "l", function() + callback() + end) + + feedkeys("l", "x") + + assert.spy(callback).called() + end) + + it("supports rhs string", function() + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", "l", "o42") + + feedkeys("l", "x") + + h.assert_buf_lines(split.bufnr, { + "", + "42", + }) + end) + + it("supports o.remap=true", function() + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", "k", "o42") + split:map("n", "l", "k", { remap = true }) + + feedkeys("k", "x") + feedkeys("l", "x") + + h.assert_buf_lines(split.bufnr, { + "", + "42", + "42", + }) + end) + + it("supports o.remap=false", function() + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", "k", "o42") + split:map("n", "l", "k", { remap = false }) + + feedkeys("k", "x") + feedkeys("l", "x") + + h.assert_buf_lines(split.bufnr, { + "", + "42", + }) + end) + + it("throws if .bufnr is nil", function() + split = Split({ + size = 20, + }) + + split.bufnr = nil + + local ok, result = pcall(function() + split:map("n", "k", "o42") + end) + + eq(ok, false) + eq(type(string.match(result, "buffer not found")), "string") + end) + end) + + h.describe_flipping_feature("lua_keymap", "method :unmap", function() + it("works before :mount", function() + split = Split({ + size = 20, + }) + + split:map("n", "l", "o42") + + split:unmap("n", "l") + + split:mount() + + feedkeys("l", "x") + + h.assert_buf_lines(split.bufnr, { + "", + }) + end) + + it("works after :mount", function() + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", "l", "o42") + + split:unmap("n", "l") + + feedkeys("l", "x") + + h.assert_buf_lines(split.bufnr, { + "", + }) + end) + + it("supports lhs string", function() + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", "l", "o42") + + split:unmap("n", "l") + + feedkeys("l", "x") + + h.assert_buf_lines(split.bufnr, { + "", + }) + end) + + it("supports lhs table", function() + split = Split({ + size = 20, + }) + + split:mount() + + split:map("n", "k", "o42") + split:map("n", "l", "o42") + + split:unmap("n", { "k", "l" }) + + feedkeys("k", "x") + feedkeys("l", "x") + + h.assert_buf_lines(split.bufnr, { + "", + }) + end) + + it("throws if .bufnr is nil", function() + split = Split({ + size = 20, + }) + + split.bufnr = nil + + local ok, result = pcall(function() + split:unmap("n", "l") + end) + + eq(ok, false) + eq(type(string.match(result, "buffer not found")), "string") + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :on", function() + it("works before :mount", function() + local callback = spy.new(function() end) + + split = Split({ + size = 20, + }) + + split:on(event.InsertEnter, function() + callback() + end) + + split:mount() + + feedkeys("i", "x") + feedkeys("", "x") + + assert.spy(callback).called() + end) + + it("works after :mount", function() + local callback = spy.new(function() end) + + split = Split({ + size = 20, + }) + + split:mount() + + split:on(event.InsertEnter, function() + callback() + end) + + feedkeys("i", "x") + feedkeys("", "x") + + assert.spy(callback).called() + end) + + it("throws if .bufnr is nil", function() + split = Split({ + size = 20, + }) + + split.bufnr = nil + + local ok, result = pcall(function() + split:on(event.InsertEnter, function() end) + end) + + eq(ok, false) + eq(type(string.match(result, "buffer not found")), "string") + end) + end) + + h.describe_flipping_feature("lua_autocmd", "method :off", function() + it("works before :mount", function() + local callback = spy.new(function() end) + + split = Split({ + size = 20, + }) + + split:on(event.InsertEnter, function() + callback() + end) + + split:off(event.InsertEnter) + + split:mount() + + feedkeys("i", "x") + feedkeys("", "x") + + assert.spy(callback).not_called() + end) + + it("works after :mount", function() + local callback = spy.new(function() end) + + split = Split({ + size = 20, + }) + + split:mount() + + split:on(event.InsertEnter, function() + callback() + end) + + split:off(event.InsertEnter) + + feedkeys("i", "x") + feedkeys("", "x") + + assert.spy(callback).not_called() + end) + + it("throws if .bufnr is nil", function() + split = Split({ + size = 20, + }) + + split.bufnr = nil + + local ok, result = pcall(function() + split:off() + end) + + eq(ok, false) + eq(type(string.match(result, "buffer not found")), "string") + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/text/init_spec.lua b/bundle/nui.nvim/tests/nui/text/init_spec.lua new file mode 100644 index 000000000..b41b2a5b1 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/text/init_spec.lua @@ -0,0 +1,284 @@ +pcall(require, "luacov") + +local Text = require("nui.text") +local h = require("tests.helpers") +local spy = require("luassert.spy") + +local eq, tbl_omit = h.eq, h.tbl_omit + +describe("nui.text", function() + local multibyte_char + + before_each(function() + multibyte_char = "║" + end) + + it("can clone nui.text object", function() + local hl_group = "NuiTextTest" + + local t1 = Text("42", hl_group) + + t1.extmark.id = 42 + local t2 = Text(t1) + eq(t2:content(), t1:content()) + eq(t2.extmark, tbl_omit(t1.extmark, { "id" })) + + t2.extmark.id = 42 + local t3 = Text(t2) + eq(t3:content(), t2:content()) + eq(t3.extmark, tbl_omit(t2.extmark, { "id" })) + end) + + it("can clone nui.text object overriding extmark", function() + local hl_group = "NuiTextTest" + local hl_group_override = "NuiTextTestOverride" + + local t1 = Text("42", hl_group) + + t1.extmark.id = 42 + local t2 = Text(t1, hl_group_override) + eq(t2:content(), t1:content()) + eq(t2.extmark, { hl_group = hl_group_override }) + + local t3 = Text(t2, { id = 42, hl_group = hl_group }) + eq(t3:content(), t2:content()) + eq(t3.extmark, { hl_group = hl_group }) + end) + + describe("method :set", function() + it("works", function() + local hl_group = "NuiTextTest" + local hl_group_override = "NuiTextTestOverride" + + local text = Text("42", hl_group) + + eq(text:content(), "42") + eq(text:length(), 2) + eq(text.extmark, { + hl_group = hl_group, + }) + + text.extmark.id = 42 + + text:set("3") + eq(text:content(), "3") + eq(text:length(), 1) + eq(text.extmark, { + hl_group = hl_group, + id = 42, + }) + + text:set("9", hl_group_override) + eq(text:content(), "9") + eq(text.extmark, { + hl_group = hl_group_override, + id = 42, + }) + + text:set("11", { hl_group = hl_group }) + eq(text:content(), "11") + eq(text.extmark, { + hl_group = hl_group, + id = 42, + }) + + text.extmark.id = nil + + text:set("42", { id = 42, hl_group = hl_group }) + eq(text:content(), "42") + eq(text.extmark, { hl_group = hl_group }) + end) + end) + + describe("method :content", function() + it("works", function() + local content = "42" + local text = Text(content) + eq(text:content(), content) + + local multibyte_content = multibyte_char + local multibyte_text = Text(multibyte_content) + eq(multibyte_text:content(), multibyte_content) + end) + end) + + describe("method :length", function() + it("works", function() + local content = "42" + local text = Text(content) + eq(text:length(), 2) + eq(text:length(), vim.fn.strlen(content)) + + local multibyte_content = multibyte_char + local multibyte_text = Text(multibyte_content) + eq(multibyte_text:length(), 3) + eq(multibyte_text:length(), vim.fn.strlen(multibyte_content)) + end) + end) + + describe("method :width", function() + it("works", function() + local content = "42" + local text = Text(content) + eq(text:width(), 2) + eq(text:width(), vim.fn.strwidth(content)) + + local multibyte_content = multibyte_char + local multibyte_text = Text(multibyte_content) + eq(multibyte_text:width(), 1) + eq(multibyte_text:width(), vim.fn.strwidth(multibyte_content)) + end) + end) + + describe("method", function() + local winid, bufnr + local initial_lines + + before_each(function() + winid = vim.api.nvim_get_current_win() + bufnr = vim.api.nvim_create_buf(false, true) + + vim.api.nvim_win_set_buf(winid, bufnr) + + initial_lines = { " 1", multibyte_char .. " 2", " 3" } + end) + + after_each(function() + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + local function reset_lines(lines) + initial_lines = lines or initial_lines + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, initial_lines) + end + + describe(":highlight", function() + local hl_group, ns, ns_id + local linenr, byte_start + local text + + before_each(function() + hl_group = "NuiTextTest" + ns = "NuiTest" + ns_id = vim.api.nvim_create_namespace(ns) + end) + + it("is applied with :render", function() + reset_lines() + linenr, byte_start = 1, 0 + text = Text("a", hl_group) + text:render(bufnr, ns_id, linenr, byte_start) + h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group) + end) + + it("is applied with :render_char", function() + reset_lines() + linenr, byte_start = 1, 0 + text = Text(multibyte_char, hl_group) + text:render_char(bufnr, ns_id, linenr, byte_start) + h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group) + end) + + it("can highlight existing buffer text", function() + reset_lines() + linenr, byte_start = 2, 0 + text = Text(initial_lines[linenr], hl_group) + text:highlight(bufnr, ns_id, linenr, byte_start) + h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group) + end) + + it("does not create multiple extmarks", function() + reset_lines() + linenr, byte_start = 2, 0 + text = Text(initial_lines[linenr], hl_group) + + text:highlight(bufnr, ns_id, linenr, byte_start) + h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group) + text:highlight(bufnr, ns_id, linenr, byte_start) + h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group) + text:highlight(bufnr, ns_id, linenr, byte_start) + h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group) + end) + end) + + describe(":render", function() + it("works on line with singlebyte characters", function() + reset_lines() + + local text = Text("a") + + spy.on(text, "highlight") + + text:render(bufnr, -1, 1, 1) + + assert.spy(text.highlight).was_called(1) + assert.spy(text.highlight).was_called_with(text, bufnr, -1, 1, 1) + + h.assert_buf_lines(bufnr, { + " a1", + initial_lines[2], + initial_lines[3], + }) + end) + + it("works on line with multibyte characters", function() + reset_lines() + + local text = Text("a") + + spy.on(text, "highlight") + + text:render(bufnr, -1, 2, vim.fn.strlen(multibyte_char)) + + assert.spy(text.highlight).was_called(1) + assert.spy(text.highlight).was_called_with(text, bufnr, -1, 2, vim.fn.strlen(multibyte_char)) + + h.assert_buf_lines(bufnr, { + initial_lines[1], + multibyte_char .. "a2", + initial_lines[3], + }) + end) + end) + + describe(":render_char", function() + it("works on line with singlebyte characters", function() + reset_lines() + + local text = Text("a") + + spy.on(text, "highlight") + + text:render_char(bufnr, -1, 1, 1) + + assert.spy(text.highlight).was_called(1) + assert.spy(text.highlight).was_called_with(text, bufnr, -1, 1, 1) + + h.assert_buf_lines(bufnr, { + " a1", + initial_lines[2], + initial_lines[3], + }) + end) + + it("works on line with multibyte characters", function() + reset_lines() + + local text = Text("a") + + spy.on(text, "highlight") + + text:render_char(bufnr, -1, 2, 1) + + assert.spy(text.highlight).was_called(1) + assert.spy(text.highlight).was_called_with(text, bufnr, -1, 2, vim.fn.strlen(multibyte_char)) + + h.assert_buf_lines(bufnr, { + initial_lines[1], + multibyte_char .. "a2", + initial_lines[3], + }) + end) + end) + end) +end) diff --git a/bundle/nui.nvim/tests/nui/tree/init_spec.lua b/bundle/nui.nvim/tests/nui/tree/init_spec.lua new file mode 100644 index 000000000..53e116284 --- /dev/null +++ b/bundle/nui.nvim/tests/nui/tree/init_spec.lua @@ -0,0 +1,895 @@ +pcall(require, "luacov") + +local Text = require("nui.text") +local Tree = require("nui.tree") +local h = require("tests.helpers") + +local eq = h.eq + +describe("nui.tree", function() + local winid, bufnr + + before_each(function() + winid = vim.api.nvim_get_current_win() + bufnr = vim.api.nvim_create_buf(false, true) + + vim.api.nvim_win_set_buf(winid, bufnr) + end) + + after_each(function() + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + describe("(#deprecated) o.winid", function() + it("throws if missing", function() + local ok, err = pcall(Tree, {}) + eq(ok, false) + eq(type(string.match(err, "missing bufnr")), "string") + end) + + it("throws if invalid", function() + local ok, err = pcall(Tree, { winid = 999 }) + eq(ok, false) + eq(type(string.match(err, "invalid winid ")), "string") + end) + + it("sets t.winid and t.bufnr properly", function() + local tree = Tree({ winid = winid }) + + eq(tree.winid, winid) + eq(tree.bufnr, bufnr) + end) + end) + + describe("o.bufnr", function() + it("throws if missing", function() + local ok, err = pcall(Tree, {}) + eq(ok, false) + eq(type(string.match(err, "missing bufnr")), "string") + end) + + it("throws if invalid", function() + local ok, err = pcall(Tree, { bufnr = 999 }) + eq(ok, false) + eq(type(string.match(err, "invalid bufnr ")), "string") + end) + + it("sets t.bufnr properly", function() + local tree = Tree({ bufnr = bufnr }) + + eq(tree.winid, nil) + eq(tree.bufnr, bufnr) + end) + end) + + it("throws on duplicated node id", function() + local ok, err = pcall(Tree, { + bufnr = bufnr, + nodes = { + Tree.Node({ id = "id", text = "text" }), + Tree.Node({ id = "id", text = "text" }), + }, + }) + eq(ok, false) + eq(type(err), "string") + end) + + it("sets default buf options emulating scratch-buffer", function() + local tree = Tree({ bufnr = bufnr }) + + h.assert_buf_options(tree.bufnr, { + bufhidden = "hide", + buflisted = false, + buftype = "nofile", + swapfile = false, + }) + end) + + describe("(#deprecated) o.win_options", function() + it("sets default values for handling folds", function() + local tree = Tree({ winid = winid }) + + h.assert_win_options(tree.winid, { + foldmethod = "manual", + foldcolumn = "0", + wrap = false, + }) + end) + + it("sets values", function() + local initial_statusline = vim.api.nvim_win_get_option(winid, "statusline") + + local statusline = "test: win_options " .. math.random() + local tree = Tree({ + winid = winid, + win_options = { + statusline = statusline, + }, + }) + + h.assert_win_options(tree.winid, { + statusline = statusline, + }) + + vim.api.nvim_win_set_option(tree.winid, "statusline", initial_statusline) + end) + + it("has no effect if o.bufnr is present", function() + local initial_statusline = vim.api.nvim_win_get_option(winid, "statusline") + + Tree({ + bufnr = bufnr, + win_options = { + statusline = "test: win_options" .. math.random(), + }, + }) + + h.assert_win_options(winid, { + statusline = initial_statusline, + }) + end) + end) + + it("sets t.ns_id if o.ns_id is string", function() + local ns = "NuiTreeTest" + local tree = Tree({ bufnr = bufnr, ns_id = ns }) + + local namespaces = vim.api.nvim_get_namespaces() + + eq(tree.ns_id, namespaces[ns]) + end) + + it("sets t.ns_id if o.ns_id is number", function() + local ns = "NuiTreeTest" + local ns_id = vim.api.nvim_create_namespace(ns) + local tree = Tree({ bufnr = bufnr, ns_id = ns_id }) + + eq(tree.ns_id, ns_id) + end) + + it("uses o.get_node_id if provided", function() + local node_d2 = Tree.Node({ key = "depth two" }) + local node_d1 = Tree.Node({ key = "depth one" }, { node_d2 }) + Tree({ + bufnr = bufnr, + nodes = { node_d1 }, + get_node_id = function(node) + return node.key + end, + }) + + eq(node_d1:get_id(), node_d1.key) + eq(node_d2:get_id(), node_d2.key) + end) + + describe("default get_node_id", function() + it("returns id using n.id", function() + local node = Tree.Node({ id = "id", text = "text" }) + Tree({ bufnr = bufnr, nodes = { node } }) + + eq(node:get_id(), "-id") + end) + + it("returns id using parent_id + depth + n.text", function() + local node_d2 = Tree.Node({ text = { "depth two a", Text("depth two b") } }) + local node_d1 = Tree.Node({ text = "depth one" }, { node_d2 }) + Tree({ bufnr = bufnr, nodes = { node_d1 } }) + + eq(node_d1:get_id(), string.format("-%s-%s", node_d1:get_depth(), node_d1.text)) + eq( + node_d2:get_id(), + string.format( + "%s-%s-%s", + node_d2:get_parent_id(), + node_d2:get_depth(), + table.concat({ node_d2.text[1], node_d2.text[2]:content() }, "-") + ) + ) + end) + + it("returns id using random number", function() + math.randomseed(0) + local expected_id = "-" .. math.random() + math.randomseed(0) + + local node = Tree.Node({}) + Tree({ bufnr = bufnr, nodes = { node } }) + + eq(node:get_id(), expected_id) + end) + end) + + it("uses o.prepare_node if provided", function() + local function prepare_node(node, parent_node) + if not parent_node then + return node.text + end + + return parent_node.text .. ":" .. node.text + end + + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, { + Tree.Node({ text = "b-1" }), + Tree.Node({ text = "b-2" }), + }), + Tree.Node({ text = "c" }), + } + + nodes[2]:expand() + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + prepare_node = prepare_node, + }) + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + "a", + "b", + "b:b-1", + "b:b-2", + "c", + }) + end) + + describe("default prepare_node", function() + it("throws if missing n.text", function() + local nodes = { + Tree.Node({ txt = "a" }), + Tree.Node({ txt = "b" }), + Tree.Node({ txt = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + }) + + local ok, err = pcall(tree.render, tree) + eq(ok, false) + eq(type(err), "string") + end) + + it("uses n.text", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = { "b-1", "b-2" } }), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + }) + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " a", + " b-1", + " b-2", + " c", + }) + end) + + it("renders arrow if children are present", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, { + Tree.Node({ text = "b-1" }), + Tree.Node({ text = { "b-2", "b-3" } }), + }), + Tree.Node({ text = "c" }), + } + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + }) + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " a", + " b", + " c", + }) + + nodes[2]:expand() + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " a", + " b", + " b-1", + " b-2", + " b-3", + " c", + }) + end) + end) + + describe("method :get_node", function() + it("can get node under cursor", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + }) + + tree:render() + + local linenr = 3 + + vim.api.nvim_win_set_cursor(winid, { linenr, 0 }) + + eq({ tree:get_node() }, { nodes[3], linenr, linenr }) + end) + + it("can get node with id", function() + local b_node_children = { + Tree.Node({ text = "b-1" }), + Tree.Node({ text = { "b-2", "b-3" } }), + } + + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, b_node_children), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return type(node.text) == "table" and table.concat(node.text, "-") or node.text + end, + }) + + tree:render() + + eq({ tree:get_node("b") }, { nodes[2], 2, 2 }) + + tree:get_node("b"):expand() + tree:render() + + eq({ tree:get_node("b-2-b-3") }, { b_node_children[2], 4, 5 }) + end) + + it("can get node on linenr", function() + local b_node_children = { + Tree.Node({ id = "b-1-b-2", text = { "b-1", "b-2" } }), + } + + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, b_node_children), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + }) + + tree:render() + + eq({ tree:get_node(1) }, { nodes[1], 1, 1 }) + + tree:get_node(2):expand() + tree:render() + + eq({ tree:get_node(3) }, { b_node_children[1], 3, 4 }) + eq({ tree:get_node(4) }, { b_node_children[1], 3, 4 }) + end) + end) + + describe("method :get_nodes", function() + it("can get nodes at root", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, { + Tree.Node({ text = "b-1" }), + }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + }) + + eq(tree:get_nodes(), nodes) + end) + + it("can get nodes under parent node", function() + local child_nodes = { + Tree.Node({ text = "b-1" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, child_nodes), + }, + get_node_id = function(node) + return node.text + end, + }) + + eq(tree:get_nodes("b"), child_nodes) + end) + end) + + describe("method :add_node", function() + it("throw if invalid parent_id", function() + local tree = Tree({ + bufnr = bufnr, + nodes = { + Tree.Node({ text = "x" }), + }, + }) + + local ok, err = pcall(tree.add_node, tree, Tree.Node({ text = "y" }), "invalid_parent_id") + eq(ok, false) + eq(type(err), "string") + end) + + it("can add node at root", function() + local tree = Tree({ + bufnr = bufnr, + nodes = { + Tree.Node({ text = "x" }), + }, + }) + + tree:add_node(Tree.Node({ text = "y" })) + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " x", + " y", + }) + + tree:add_node(Tree.Node({ text = "z" })) + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " x", + " y", + " z", + }) + end) + + it("can add node under parent node", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, { + Tree.Node({ text = "b-1" }), + }), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + }) + + tree:add_node(Tree.Node({ text = "b-2" }), "b") + + tree:get_node("b"):expand() + + tree:add_node(Tree.Node({ text = "c-1" }), "c") + + tree:get_node("c"):expand() + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " a", + " b", + " b-1", + " b-2", + " c", + " c-1", + }) + end) + end) + + describe("method :set_nodes", function() + it("throw if invalid parent_id", function() + local tree = Tree({ + bufnr = bufnr, + nodes = { + Tree.Node({ text = "x" }), + }, + }) + + local ok, err = pcall(tree.set_nodes, tree, {}, "invalid_parent_id") + eq(ok, false) + eq(type(err), "string") + end) + + it("can set nodes at root", function() + local tree = Tree({ + bufnr = bufnr, + nodes = { + Tree.Node({ text = "x" }), + }, + }) + + tree:set_nodes({ + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }), + }) + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " a", + " b", + }) + + tree:set_nodes({ + Tree.Node({ text = "c" }), + }) + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " c", + }) + end) + + it("can set nodes under parent node", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, { + Tree.Node({ text = "b-1" }), + }), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + }) + + tree:set_nodes({ + Tree.Node({ text = "b-2" }), + }, "b") + + tree:get_node("b"):expand() + + tree:set_nodes({ + Tree.Node({ text = "c-1" }), + Tree.Node({ text = "c-2" }), + }, "c") + + tree:get_node("c"):expand() + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " a", + " b", + " b-2", + " c", + " c-1", + " c-2", + }) + end) + end) + + describe("method :remove_node", function() + it("can remove node w/o parent", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, { + Tree.Node({ text = "b-1" }), + }), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + }) + + tree:remove_node("a") + + tree:get_node("b"):expand() + + tree:render() + + eq( + vim.tbl_map(function(node) + return node:get_id() + end, tree:get_nodes()), + { "b", "c" } + ) + + h.assert_buf_lines(tree.bufnr, { + " b", + " b-1", + " c", + }) + end) + + it("can remove node w/ parent", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, { + Tree.Node({ text = "b-1" }), + }), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + }) + + tree:remove_node("b-1") + + tree:render() + + eq(tree:get_node("b"):get_child_ids(), {}) + + h.assert_buf_lines(tree.bufnr, { + " a", + " b", + " c", + }) + end) + + it("removes children nodes recursively", function() + local nodes = { + Tree.Node({ text = "a" }, { + Tree.Node({ text = "a-1" }, { + Tree.Node({ text = "a-1-x" }), + }), + }), + } + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + }) + h.neq(tree:get_node("a"), nil) + h.neq(tree:get_node("a-1"), nil) + h.neq(tree:get_node("a-1-x"), nil) + + tree:remove_node("a") + + eq(tree:get_node("a"), nil) + eq(tree:get_node("a-1"), nil) + eq(tree:get_node("a-1-x"), nil) + end) + end) + + describe("method :render", function() + it("handles unexpected case of missing node", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + }) + + -- this should not happen normally + tree.nodes.by_id["a"] = nil + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + " b", + " c", + }) + end) + + it("skips node if o.prepare_node returns nil", function() + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }), + Tree.Node({ text = "c" }), + } + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + prepare_node = function(node) + if node:get_id() == "b" then + return nil + end + + return node.text + end, + }) + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + "a", + "c", + }) + end) + + it("supports param linenr_start", function() + local b_node_children = { + Tree.Node({ text = "b-1" }), + Tree.Node({ text = "b-2" }), + } + local nodes = { + Tree.Node({ text = "a" }), + Tree.Node({ text = "b" }, b_node_children), + } + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "NuiTreeTest", + "", + "NuiTreeTest", + }) + + local tree = Tree({ + bufnr = bufnr, + nodes = nodes, + get_node_id = function(node) + return node.text + end, + }) + + tree:render(2) + + h.assert_buf_lines(tree.bufnr, { + "NuiTreeTest", + " a", + " b", + "NuiTreeTest", + }) + + nodes[2]:expand() + + tree:render() + + h.assert_buf_lines(tree.bufnr, { + "NuiTreeTest", + " a", + " b", + " b-1", + " b-2", + "NuiTreeTest", + }) + + nodes[2]:collapse() + + tree:render(3) + + h.assert_buf_lines(tree.bufnr, { + "NuiTreeTest", + "", + " a", + " b", + "NuiTreeTest", + }) + end) + end) +end) + +describe("nui.tree.Node", function() + describe("method :has_children", function() + it("works before initialization", function() + local node_wo_children = Tree.Node({ text = "a" }) + local node_w_children = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) }) + + eq(node_wo_children._initialized, false) + eq(node_wo_children:has_children(), false) + + eq(node_w_children._initialized, false) + eq(type(node_w_children.__children), "table") + eq(node_w_children:has_children(), true) + end) + + it("works after initialization", function() + local node_wo_children = Tree.Node({ text = "a" }) + local node_w_children = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) }) + + Tree({ + bufnr = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), + nodes = { node_wo_children, node_w_children }, + }) + + eq(node_wo_children._initialized, true) + eq(node_wo_children:has_children(), false) + + eq(node_w_children._initialized, true) + eq(type(node_w_children.__children), "nil") + eq(node_w_children:has_children(), true) + end) + end) + + describe("method :expand", function() + it("returns true if not already expanded", function() + local node = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) }) + eq(node:is_expanded(), false) + eq(node:expand(), true) + eq(node:is_expanded(), true) + end) + + it("returns false if already expanded", function() + local node = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) }) + node:expand() + eq(node:is_expanded(), true) + eq(node:expand(), false) + eq(node:is_expanded(), true) + end) + + it("does not work w/o children", function() + local node = Tree.Node({ text = "a" }) + eq(node:is_expanded(), false) + eq(node:expand(), false) + eq(node:is_expanded(), false) + end) + end) + + describe("method :collapse", function() + it("returns true if not already collapsed", function() + local node = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) }) + node:expand() + eq(node:is_expanded(), true) + eq(node:collapse(), true) + eq(node:is_expanded(), false) + end) + + it("returns false if already collapsed", function() + local node = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) }) + eq(node:is_expanded(), false) + eq(node:collapse(), false) + eq(node:is_expanded(), false) + end) + + it("does not work w/o children", function() + local node = Tree.Node({ text = "a" }) + eq(node:is_expanded(), false) + eq(node:collapse(), false) + eq(node:is_expanded(), false) + end) + end) +end) diff --git a/config/plugins/neo-tree.vim b/config/plugins/neo-tree.vim new file mode 100644 index 000000000..9bd382541 --- /dev/null +++ b/config/plugins/neo-tree.vim @@ -0,0 +1,2 @@ +nnoremap NeoTreeFocusToggle +