Meh I'll figure out submodules later

This commit is contained in:
mustard 2025-09-16 01:01:02 +02:00
parent 4ca9d44a90
commit 8cb281f436
352 changed files with 66107 additions and 0 deletions

22
.config/nvim/init.lua Normal file
View file

@ -0,0 +1,22 @@
-- Basic settings
vim.o.number = true -- Enable line numbers
vim.o.tabstop = 2 -- Number of spaces a tab represents
vim.o.shiftwidth = 2 -- Number of spaces for each indentation
vim.o.expandtab = true -- Convert tabs to spaces
vim.o.smartindent = true -- Automatically indent new lines
vim.o.wrap = true -- Disable line wrapping
vim.o.cursorline = true -- Highlight the current line
vim.o.termguicolors = true -- Enable 24-bit RGB colors
vim.o.clipboard = "unnamedplus"
-- Syntax highlighting and filetype plugins
vim.cmd('syntax enable')
vim.cmd('filetype plugin indent on')
require('neo-tree').setup({
filesystem = {
filtered_items = {
hide_dotfiles = false
}
}
})

View file

@ -0,0 +1,10 @@
coverage:
status:
project:
default:
informational: true
only_pulls: true
patch:
default:
informational: true
only_pulls: true

View file

@ -0,0 +1,95 @@
name: Bug Report
description: File a bug / issue.
title: "BUG: "
labels: [bug]
body:
- type: markdown
attributes:
value: |
**Before** reporting an issue, make sure to read [`:h neo-tree.txt`](https://github.com/nvim-neo-tree/neo-tree.nvim/blob/v3.x/doc/neo-tree.txt) and search [existing issues](https://github.com/nvim-neo-tree/neo-tree.nvim/issues). Usage questions such as ***"How do I...?"*** belong in [Discussions](https://github.com/nvim-neo-tree/neo-tree.nvim/discussions) and will be closed.
- type: checkboxes
attributes:
label: Did you check docs and existing issues?
description: Make sure you checked all of the below before submitting an issue
options:
- label: I have read all the docs.
required: true
- label: I have searched the existing issues.
required: true
- label: I have searched the existing discussions.
required: true
- type: input
attributes:
label: "Neovim Version (nvim -v)"
placeholder: "NVIM v0.10.3"
validations:
required: true
- type: input
attributes:
label: "Operating System / Version"
placeholder: "MacOS 11.5"
validations:
required: true
- type: textarea
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is. Please include any related errors you see in Neovim.
validations:
required: true
- type: textarea
attributes:
label: Screenshots, Traceback
description: Screenshot and traceback if exists. Not required.
validations:
required: false
- type: textarea
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior. Describe with the exact commands and keypresses.
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Your Configuration
description: Minimal `init.lua` to reproduce this issue. Save as `repro.lua` and run with `nvim -u repro.lua`
value: |
-- template from https://lazy.folke.io/developers#reprolua, feel free to replace if you have your own minimal init.lua
vim.env.LAZY_STDPATH = ".repro"
load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"))()
require("lazy.minit").repro({
spec = {
{
"nvim-neo-tree/neo-tree.nvim",
branch = "v3.x", -- or "main"
dependencies = {
"nvim-lua/plenary.nvim",
"nvim-tree/nvim-web-devicons", -- not strictly required, but recommended
"MunifTanjim/nui.nvim",
-- { "3rd/image.nvim", opts = {} }, -- Optional image support
},
lazy = false,
---@module "neo-tree"
---@type neotree.Config?
opts = {
-- fill any relevant options here
},
}
},
})
vim.g.mapleader = " "
vim.keymap.set("n", "<leader>e", "<Cmd>Neotree<CR>")
-- do anything else you need to do to reproduce the issue
render: Lua
validations:
required: true

View file

@ -0,0 +1,36 @@
name: Feature Request
description: Suggest a new feature.
title: "FEATURE: "
labels: [enhancement]
body:
- type: checkboxes
attributes:
label: Did you check the docs?
description: Make sure you read all the docs before submitting a feature request.
options:
- label: I have read all the docs.
required: true
- type: textarea
validations:
required: true
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
- type: textarea
validations:
required: true
attributes:
label: Describe the solution you'd like.
description: A clear and concise description of what you want to happen.
- type: textarea
validations:
required: false
attributes:
label: Describe alternatives you've considered.
description: A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
validations:
required: false
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.

View file

@ -0,0 +1,33 @@
{
"$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json",
"diagnostics": {
"libraryFiles": "Disable"
},
"runtime": {
"version": "LuaJIT",
"path": [
"lua/?.lua",
"lua/?/init.lua",
"library/?.lua",
"library/?/init.lua"
]
},
"workspace": {
"checkThirdParty": "Disable",
"library": [
"$PWD/.dependencies/pack/vendor/start/plenary.nvim",
"$PWD/.dependencies/pack/vendor/start/nui.nvim",
"$PWD/.dependencies/pack/vendor/start/nvim-web-devicons",
"$PWD/.dependencies/pack/vendor/start/snacks.nvim",
"${3rd}/luassert",
"${3rd}/busted",
"${3rd}/luv",
"$VIMRUNTIME"
],
"ignoreDir": [
".dependencies",
".luarocks",
".lua"
]
}
}

View file

@ -0,0 +1,33 @@
{
"$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json",
"diagnostics": {
"libraryFiles": "Disable"
},
"runtime": {
"version": "LuaJIT",
"path": [
"lua/?.lua",
"lua/?/init.lua",
"library/?.lua",
"library/?/init.lua"
]
},
"workspace": {
"checkThirdParty": "Disable",
"library": [
"$PWD/.dependencies/pack/vendor/start/plenary.nvim",
"$PWD/.dependencies/pack/vendor/start/nui.nvim",
"$PWD/.dependencies/pack/vendor/start/nvim-web-devicons",
"$PWD/.dependencies/pack/vendor/start/snacks.nvim",
"${3rd}/luassert",
"${3rd}/busted",
"${3rd}/luv",
"$VIMRUNTIME"
],
"ignoreDir": [
".dependencies",
".luarocks",
".lua"
]
}
}

View file

@ -0,0 +1,84 @@
name: CI
on:
push:
branches:
- main
- v1.x
- v2.x
- v3.x
pull_request:
workflow_dispatch:
jobs:
stylua-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check formatting
uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
args: --color always --check lua/
plenary-tests:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
rev: nightly/nvim-linux-x86_64.tar.gz
- os: ubuntu-22.04
rev: v0.8.3/nvim-linux64.tar.gz
- os: ubuntu-22.04
rev: v0.9.5/nvim-linux64.tar.gz
- os: ubuntu-22.04
rev: v0.10.4/nvim-linux-x86_64.tar.gz
steps:
- uses: actions/checkout@v4
- run: date +%F > todays-date
- name: Restore cache for today's nightly.
uses: actions/cache@v4
with:
path: build
key: ${{ runner.os }}-${{ matrix.rev }}-${{ hashFiles('todays-date') }}
- name: Prepare
run: |
test -d build || {
mkdir -p build
curl -sL "https://github.com/neovim/neovim/releases/download/${{ matrix.rev }}" | tar xzf - --strip-components=1 -C "${PWD}/build"
}
# - 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/bin:${PATH}"
make setup
make test
# - name: Upload coverage to Codecov
# uses: codecov/codecov-action@v2

View file

@ -0,0 +1,44 @@
name: Lua Language Server Diagnostics
on:
pull_request: ~
push:
branches:
- '*'
jobs:
luals-check:
strategy:
matrix:
neovim: ["0.10"]
lua: ["5.1", "luajit-master"]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: luarocks/gh-actions-lua@v10
with:
luaVersion: ${{matrix.lua}}
- name: Install lua-language-server
uses: jdx/mise-action@v2
with:
mise_toml: |
[tools]
neovim = "${{ matrix.neovim }}"
cargo-binstall = "latest"
"cargo:emmylua_check" = "latest"
"cargo:emmylua_ls" = "latest"
lua-language-server = "3.13.9"
- name: Run lua-language-server check
run: |
LUARC=".github/workflows/.luarc-${{ matrix.lua }}.json"
make luals-check CONFIGURATION="$LUARC"
- name: Run emmylua_check
continue-on-error: true # Doesn't type-check well enough to be worth erroring on, but this runs so fast we might as well help test this out.
run: |
LUARC=".github/workflows/.luarc-${{ matrix.lua }}.json"
make emmylua-check CONFIGURATION="$LUARC"

View file

@ -0,0 +1,29 @@
---
name: Push to Luarocks
on:
push:
tags:
- '*'
workflow_dispatch:
pull_request: # Will test the luarocks installation on PR, without uploading
jobs:
luarocks-upload:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # Required to count the commits
- name: Get Version
run: echo "LUAROCKS_VERSION=$(git describe --abbrev=0 --tags)" >> $GITHUB_ENV
- name: LuaRocks Upload
uses: nvim-neorocks/luarocks-tag-release@v5
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}
with:
version: ${{ env.LUAROCKS_VERSION }}
dependencies: |
plenary.nvim
nvim-web-devicons
nui.nvim

View file

@ -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 != "main" ]]; then
echo "PRs must target main"
exit 1
else
exit 0
fi

View file

@ -0,0 +1,51 @@
# 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
.dependencies
luacov.*.out
tests/repro
.repro

View file

@ -0,0 +1,24 @@
local root = vim.fs.find({ "neo-tree.nvim" }, { upward = true })[1]
local deps_dir = root .. "/.dependencies/pack/vendor/start"
return {
{
"folke/snacks.nvim",
dir = deps_dir .. "/snacks.nvim",
},
{
"nvim-neo-tree/neo-tree.nvim",
dir = root,
},
{
"MunifTanjim/nui.nvim",
dir = deps_dir .. "/nui.nvim",
},
{
"nvim-tree/nvim-web-devicons",
dir = deps_dir .. "/nvim-web-devicons",
},
{
"nvim-lua/plenary.nvim",
dir = deps_dir .. "/plenary.nvim",
},
}

View file

@ -0,0 +1,3 @@
include = {
"lua%/neo%-tree",
}

View file

@ -0,0 +1,34 @@
{
"$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json",
"diagnostics": {
"libraryFiles": "Disable"
},
"runtime": {
"version": "LuaJIT",
"path": [
"lua/?.lua",
"lua/?/init.lua",
"library/?.lua",
"library/?/init.lua"
]
},
"workspace": {
"checkThirdParty": "Disable",
"library": [
"$PWD/.dependencies/pack/vendor/start/plenary.nvim",
"$PWD/.dependencies/pack/vendor/start/nui.nvim",
"$PWD/.dependencies/pack/vendor/start/nvim-web-devicons",
"$PWD/.dependencies/pack/vendor/start/snacks.nvim",
"${3rd}/luassert",
"${3rd}/busted",
"${3rd}/luv",
"$VIMRUNTIME"
],
"ignoreDir": [
".dependencies",
".luarocks",
".lua",
".repro"
]
}
}

View file

@ -0,0 +1,6 @@
column_width = 100
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferDouble"
syntax = "LuaJIT"

View file

@ -0,0 +1 @@
**/defaults.lua

View file

@ -0,0 +1,62 @@
# 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 `main` and all Pull Requests should target this
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. This is the bare minimum documentation for an option.
- 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`
- Whether something should be mentioned in the README or just in the help file
is a completely subjective judement call that is made on a case by case basis
based on how many people are likely to be interested in that information.
- 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.
- OUR DOCUMENTATION IS NOT GOOD ENOUGH! Consider the current level of documentation
the bare minumum and not the ideal. More documentation would be greatly appreciated.

View file

@ -0,0 +1,36 @@
# --- Builder Stage ---
FROM alpine:latest AS builder
RUN apk update && apk add --no-cache \
build-base \
ninja-build \
cmake \
coreutils \
curl \
gettext-tiny-dev \
git
# Install neovim
RUN git clone --depth=1 https://github.com/neovim/neovim --branch release-0.10
RUN cd neovim && make CMAKE_BUILD_TYPE=RelWithDebInfo && make install
# --- Final Stage ---
FROM alpine:latest
RUN apk update && apk add --no-cache \
libstdc++ # Often needed for C++ applications
COPY --from=builder /usr/local/bin/nvim /usr/local/bin/nvim
COPY --from=builder /usr/local/share /usr/local/share
ARG PLUG_DIR="/root/.local/share/nvim/site/pack/packer/start"
RUN mkdir -p $PLUG_DIR
RUN apk add --no-cache git # Git is needed to clone plugins in the final image
RUN git clone --depth=1 https://github.com/nvim-lua/plenary.nvim $PLUG_DIR/plenary.nvim
RUN git clone --depth=1 https://github.com/MunifTanjim/nui.nvim $PLUG_DIR/nui.nvim
RUN git clone --depth=1 https://github.com/nvim-tree/nvim-web-devicons.git $PLUG_DIR/nvim-web-devicons
COPY . $PLUG_DIR/neo-tree.nvim
WORKDIR $PLUG_DIR/neo-tree.nvim

View file

@ -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.

View file

@ -0,0 +1,45 @@
.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' .
# Dependencies:
DEPS := ${CURDIR}/.dependencies/pack/vendor/start
$(DEPS):
mkdir -p "$(DEPS)"
$(DEPS)/nui.nvim: $(DEPS)
@test -d "$(DEPS)/nui.nvim" || git clone https://github.com/MunifTanjim/nui.nvim "$(DEPS)/nui.nvim"
$(DEPS)/nvim-web-devicons: $(DEPS)
@test -d "$(DEPS)/nvim-web-devicons" || git clone https://github.com/nvim-tree/nvim-web-devicons "$(DEPS)/nvim-web-devicons"
$(DEPS)/plenary.nvim: $(DEPS)
@test -d "$(DEPS)/plenary.nvim" || git clone https://github.com/nvim-lua/plenary.nvim "$(DEPS)/plenary.nvim"
$(DEPS)/snacks.nvim: $(DEPS)
@test -d "$(DEPS)/snacks.nvim" || git clone https://github.com/folke/snacks.nvim "$(DEPS)/snacks.nvim"
setup: $(DEPS)/nui.nvim $(DEPS)/nvim-web-devicons $(DEPS)/plenary.nvim $(DEPS)/snacks.nvim
@echo "[setup] environment ready"
.PHONY: clean
clean:
rm -rf "$(DEPS)"
CONFIGURATION = ${CURDIR}/.luarc.json
luals-check: setup
VIMRUNTIME="`nvim --clean --headless --cmd 'lua io.write(vim.env.VIMRUNTIME)' --cmd 'quit'`" lua-language-server --configpath=$(CONFIGURATION) --check=.
emmylua-check: setup
VIMRUNTIME="`nvim --clean --headless --cmd 'lua io.write(vim.env.VIMRUNTIME)' --cmd 'quit'`" emmylua_check -c $(CONFIGURATION) -i ".dependencies/**" -- .

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,86 @@
# An example install script for people using `:h packages`
$NVIM_APPNAME = if ($env:NVIM_APPNAME) { $env:NVIM_APPNAME } else { "nvim" }
$NEOTREE_DATA_HOME = if ($env:XDG_DATA_HOME) {
Join-Path $env:XDG_DATA_HOME $NVIM_APPNAME
} else {
Join-Path $HOME ".local\share\$NVIM_APPNAME"
}
###########
# Options #
###########
# You can modify the /neo-tree*/ names here, depending on how you like to organize your packages.
$NEOTREE_DIR = Join-Path $NEOTREE_DATA_HOME "site\pack\neo-tree\start"
$NEOTREE_DEPS_DIR = Join-Path $NEOTREE_DATA_HOME "site\pack\neo-tree-deps\start"
$NEOTREE_OPTIONAL_DIR = Join-Path $NEOTREE_DATA_HOME "site\pack\neo-tree-optional\start"
# Modify the optional plugins you want below:
$OPTIONAL_PLUGINS = @(
"https://github.com/nvim-tree/nvim-web-devicons.git" # for file icons
# "https://github.com/antosha417/nvim-lsp-file-operations.git" # for LSP-enhanced renames/etc.
# "https://github.com/folke/snacks.nvim.git" # for image previews
# "https://github.com/3rd/image.nvim.git" # for image previews
# "https://github.com/s1n7ax/nvim-window-picker.git" # for _with_window_picker keymaps
)
###########################
# The rest of the script. #
###########################
# Save the current directory
$ORIGINAL_DIR = Get-Location
function Invoke-GitCloneSparse {
git clone --filter=blob:none @args
if ($LASTEXITCODE -ne 0) {
Write-Error "Git clone failed with exit code $LASTEXITCODE for arguments: $($args -join ' ')"
}
}
New-Item -ItemType Directory -Path $NEOTREE_DIR, $NEOTREE_DEPS_DIR -Force | Out-Null
Write-Host "Installing neo-tree..."
Set-Location $NEOTREE_DIR
Invoke-GitCloneSparse -b v3.x "https://github.com/nvim-neo-tree/neo-tree.nvim.git"
Write-Host "Installing core dependencies..."
Set-Location $NEOTREE_DEPS_DIR
Invoke-GitCloneSparse "https://github.com/nvim-lua/plenary.nvim.git"
Invoke-GitCloneSparse "https://github.com/MunifTanjim/nui.nvim.git"
if ($OPTIONAL_PLUGINS.Count -gt 0) {
Write-Host "Installing optional plugins..."
New-Item -ItemType Directory -Path $NEOTREE_OPTIONAL_DIR -Force | Out-Null
Set-Location $NEOTREE_OPTIONAL_DIR
foreach ($repo in $OPTIONAL_PLUGINS) {
Invoke-GitCloneSparse $repo
}
}
Write-Host "Regenerating help tags..."
$PLUGIN_BASE_DIRS = @(
$NEOTREE_DIR
$NEOTREE_DEPS_DIR
$NEOTREE_OPTIONAL_DIR
)
foreach ($base_dir in $PLUGIN_BASE_DIRS) {
# Check if the base directory exists
if (Test-Path $base_dir -PathType Container) {
foreach ($doc_path in Get-ChildItem "$base_dir/*/doc" -Directory) {
Write-Host "Generating helptags for: $doc_path"
& nvim -u NONE --headless -c "helptags $doc_path" -c "q"
if ($LASTEXITCODE -ne 0) {
Write-Warning "Nvim helptags command failed for $doc_path. Exit code: $LASTEXITCODE"
}
}
} else {
Write-Host "Info: Base plugin directory not found, skipping for helptags: $base_dir"
}
}
Write-Host "Installation complete!"
Set-Location $ORIGINAL_DIR

View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# An example install script for people using `:h packages`
export NEOTREE_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/${NVIM_APPNAME:-nvim}"
###########
# Options #
###########
# You can modify the /neo-tree*/ names here, depending on how you like to organize your packages.
export NEOTREE_DIR="${NEOTREE_DATA_HOME}/site/pack/neo-tree/start"
export NEOTREE_DEPS_DIR="${NEOTREE_DATA_HOME}/site/pack/neo-tree-deps/start"
export NEOTREE_OPTIONAL_DIR="${NEOTREE_DATA_HOME}/site/pack/neo-tree-optional/start"
# Modify the optional plugins you want below:
declare -a OPTIONAL_PLUGINS=(
"https://github.com/nvim-tree/nvim-web-devicons.git" # for file icons
# "https://github.com/antosha417/nvim-lsp-file-operations.git" # for LSP-enhanced renames/etc.
# "https://github.com/folke/snacks.nvim.git" # for image previews
# "https://github.com/3rd/image.nvim.git" # for image previews
# "https://github.com/s1n7ax/nvim-window-picker.git" # for _with_window_picker keymaps
)
###########################
# The rest of the script. #
###########################
ORIGINAL_DIR="$(pwd)"
clone_sparse() {
git clone --filter=blob:none "$@"
}
mkdir -p "${NEOTREE_DIR}" "${NEOTREE_DEPS_DIR}"
echo "Installing neo-tree..."
cd "${NEOTREE_DIR}"
clone_sparse -b v3.x https://github.com/nvim-neo-tree/neo-tree.nvim.git
echo "Installing core dependencies..."
cd "${NEOTREE_DEPS_DIR}"
clone_sparse https://github.com/nvim-lua/plenary.nvim.git
clone_sparse https://github.com/MunifTanjim/nui.nvim.git
if [ ${#OPTIONAL_PLUGINS[@]} -gt 0 ]; then
echo "Installing optional plugins..."
mkdir -p "${NEOTREE_OPTIONAL_DIR}"
cd "${NEOTREE_OPTIONAL_DIR}"
for repo in "${OPTIONAL_PLUGINS[@]}"; do
clone_sparse "$repo"
done
fi
echo "Regenerating help tags..."
declare -a PLUGIN_BASE_DIRS=(
"${NEOTREE_DIR}"
"${NEOTREE_DEPS_DIR}"
"${NEOTREE_OPTIONAL_DIR}"
)
# Loop through each base directory and find all 'doc' subdirectories using glob
shopt -s nullglob # Enable nullglob for safe globbing (empty array if no matches)
for base_dir in "${PLUGIN_BASE_DIRS[@]}"; do
# Check if the base directory exists
if [ -d "$base_dir" ]; then
for doc_path in "${base_dir}"/*/doc; do
nvim -u NONE --headless -c "helptags ${doc_path}" -c "q"
done
fi
done
shopt -u nullglob # Disable nullglob
echo "Installation complete!"
cd "${ORIGINAL_DIR}"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,124 @@
local M = {}
--- To be removed in a future release, use this instead:
--- ```lua
--- require("neo-tree.command").execute({ action = "close" })
--- ```
---@deprecated
M.close_all = function()
require("neo-tree.command").execute({ action = "close" })
end
---@type neotree.Config?
local new_user_config = nil
---Updates the config of neo-tree using the latest user config passed through setup, if any.
---@return neotree.Config.Base
M.ensure_config = function()
if not M.config or new_user_config then
M.config = require("neo-tree.setup").merge_config(new_user_config)
new_user_config = nil
end
return M.config
end
---A performance-focused version for checking a specific key of a config while trying not to do expensive setup work
---@return neotree.Config.Base
M.peek_config = function()
return new_user_config or M.ensure_config()
end
---@param ignore_filetypes string[]?
---@param ignore_winfixbuf boolean?
M.get_prior_window = function(ignore_filetypes, ignore_winfixbuf)
local utils = require("neo-tree.utils")
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.prior_windows[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 and not (ignore_winfixbuf and utils.is_winfixbuf(last_win)) then
local buf = vim.api.nvim_win_get_buf(last_win)
local ft = vim.bo[buf].filetype
local bt = vim.bo[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 utils = require("neo-tree.utils")
---@type string
local base_path = assert(debug.getinfo(utils.truthy).source:match("@(.*)/utils/init.lua$"))
---@type string
local config_path = base_path .. utils.path_separator .. "defaults.lua"
---@type string[]?
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
---@type string[]
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.set_log_level = function(level)
require("neo-tree.log").set_level(level)
end
---Ideally this should only be in plugin/neo-tree.lua but lazy-loading might mean this runs before bufenter
---@param path string? The path to check
---@return boolean hijacked Whether we hijacked a buffer
local function try_netrw_hijack(path)
if not path or #path == 0 then
return false
end
local stats = (vim.uv or vim.loop).fs_stat(path)
if not stats or stats.type ~= "directory" then
return false
end
return require("neo-tree.setup.netrw").hijack()
end
---@param config neotree.Config
M.setup = function(config)
-- merging is deferred until ensure_config
new_user_config = config
if vim.v.vim_did_enter == 0 then
try_netrw_hijack(vim.fn.argv(0) --[[@as string]])
end
end
M.show_logs = function()
vim.cmd("tabnew " .. require("neo-tree.log").outfile)
end
return M

View file

@ -0,0 +1,146 @@
local log = require("neo-tree.log")
---@class neotree.collections.ListNode
---@field prev neotree.collections.ListNode?
---@field next neotree.collections.ListNode?
---@field value any
local Node = {}
function Node:new(value)
local props = { prev = nil, next = nil, value = value }
setmetatable(props, self)
self.__index = self
return props
end
---@class neotree.collections.LinkedList
---@field head neotree.collections.ListNode?
---@field tail neotree.collections.ListNode?
---@field size integer
local LinkedList = {}
---@return neotree.collections.LinkedList
function LinkedList:new()
local props = { head = nil, tail = nil, size = 0 }
setmetatable(props, self)
self.__index = self
return props
end
---@param node neotree.collections.ListNode
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
---@param node neotree.collections.ListNode
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
---@class neotree.collections.Queue
---@field _list neotree.collections.LinkedList
local Queue = {}
---@return neotree.collections.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.
---@return table? result
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 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
---@diagnostic disable-next-line: need-check-nil
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,
}

View file

@ -0,0 +1,130 @@
local parser = require("neo-tree.command.parser")
local utils = require("neo-tree.utils")
local M = {
show_key_value_completions = true,
}
---@param key_prefix string?
---@param base_path string
---@return string paths_string
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
---@param key_prefix string?
---@return string references_string
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
---@param argLead string
---@param cmdLine string
---@return string candidates_string
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.argtype_lookup[key]
if arg_type == parser.argtypes.PATH then
return get_path_completions(key .. "=", value)
elseif arg_type == parser.argtypes.REF then
return get_ref_completions(key .. "=")
elseif arg_type == parser.argtypes.LIST then
local valid_values = parser.arguments[key].values
if valid_values and not (parsed[key] and #parsed[key] > 0) then
for _, vv in ipairs(valid_values) do
if vv:find(value, 1, true) 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.argtype_lookup[key] == parser.argtypes.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

View file

@ -0,0 +1,245 @@
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,
}
-- Store the last source used for `M.execute`
M._last = {
source = nil,
position = nil,
}
---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
-- Handle source=last
if args.source == "last" then
args.source = M._last.source or nt.config.default_source
-- Restore last position if it was not specified
if args.position == nil then
args.position = M._last.position
end
-- Prevent the default source from being set to "last"
if args.source == "last" then
args.source = nt.config.sources[1]
end
end
M._last.source = args.source
M._last.position = args.position
-- 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
---@type neotree.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
-- Root paths on Windows have 3 characters ("C:\")
local root_len = vim.fn.has("win32") == 1 and 3 or 1
if #args.dir > root_len and args.dir:sub(-1) == utils.path_separator then
args.dir = args.dir:sub(1, -2)
end
path_changed = state.path ~= args.dir
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 source selector option
state.enable_source_selector = args.selector
-- 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)
return
end
if not args.dir then
args.dir = state.path
end
do_show_or_focus(args, state, force_navigate)
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)
args.reveal_file = utils.normalize_path(args.reveal_file)
-- Deal with cwd if we need to
local cwd = args.dir or state.path or manager.get_cwd(state)
if utils.is_subpath(cwd, args.reveal_file) then
args.dir = cwd
do_show_or_focus(args, state, true)
return
end
local reveal_file_parent, _ = utils.split_path(args.reveal_file) --[[@as string]]
if args.reveal_force_cwd then
args.dir = reveal_file_parent
do_show_or_focus(args, state, true)
return
end
-- if dir doesn't have the reveal_file, ignore the reveal_file
if args.dir then
args.reveal_file = nil
do_show_or_focus(args, state, true)
return
end
-- force was not specified and the file does not belong to cwd, so we need to ask the user
inputs.confirm("File not in cwd. Change cwd to " .. reveal_file_parent .. "?", function(response)
if response == true then
args.dir = reveal_file_parent
else
args.reveal_file = nil
end
do_show_or_focus(args, state, true)
end)
end
return M

View file

@ -0,0 +1,208 @@
local uv = vim.uv or vim.loop
local utils = require("neo-tree.utils")
local _compat = require("neo-tree.utils._compat")
---@enum neotree.command.ParserArgument.Type
local argtype = {
FLAG = "<FLAG>",
LIST = "<LIST>",
PATH = "<PATH>",
REF = "<REF>",
}
---@class neotree.command.Parser
---@field argtypes table<string, neotree.command.ParserArgument.Type>
local M = {
argtypes = argtype,
}
---@param all_source_names string[]
M.setup = function(all_source_names)
local source_names = vim.deepcopy(all_source_names, _compat.noref())
table.insert(source_names, "migrations")
-- A special source referring to the last used source.
table.insert(source_names, "last")
---@class neotree.command.ParserArgument
---@field type neotree.command.ParserArgument.Type
-- For lists, the first value is the default value.
---@class neotree.command.ParserArguments
---@field [string] neotree.command.ParserArgument
---@field values string[]
local arguments = {
action = {
type = M.argtypes.LIST,
values = {
"close",
"focus",
"show",
},
},
position = {
type = M.argtypes.LIST,
values = {
"left",
"right",
"top",
"bottom",
"float",
"current",
},
},
source = {
type = M.argtypes.LIST,
values = source_names,
},
dir = { type = M.argtypes.PATH, stat_type = "directory" },
reveal_file = { type = M.argtypes.PATH, stat_type = "file" },
git_base = { type = M.argtypes.REF },
toggle = { type = M.argtypes.FLAG },
reveal = { type = M.argtypes.FLAG },
reveal_force_cwd = { type = M.argtypes.FLAG },
selector = { type = M.argtypes.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.argtypes.LIST then
table.insert(list_args, name)
for _, vv in ipairs(def.values) do
reverse_lookup[tostring(vv)] = name
end
elseif def.type == M.argtypes.PATH then
table.insert(path_args, name)
elseif def.type == M.argtypes.FLAG then
table.insert(flag_args, name)
reverse_lookup[name] = M.argtypes.FLAG
elseif def.type == M.argtypes.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.argtype_lookup = arg_type_lookup
M.reverse_lookup = reverse_lookup
end
---@param path string
---@param validate_type string?
M.resolve_path = function(path, validate_type)
path = vim.fs.normalize(path)
local expanded = vim.fn.expand(path)
local abs_path = vim.fn.fnamemodify(expanded, ":p")
if validate_type then
local stat = uv.fs_stat(abs_path)
if not stat or stat.type ~= validate_type then
error("Invalid path: " .. path .. " is not a " .. validate_type)
end
end
return abs_path
end
---@param ref string
M.verify_git_ref = function(ref)
local ok, _ = utils.execute_command("git rev-parse --verify " .. ref)
return ok
end
---@class neotree.command.Parser.Parsed
---@field [string] string|boolean
---@param result neotree.command.Parser.Parsed
---@param arg string
local parse_arg = function(result, arg)
if type(arg) ~= "string" then
return
end
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.argtypes.PATH then
result[key] = M.resolve_path(value, def.stat_type)
elseif def.type == M.argtypes.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.argtypes.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 = uv.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.argtypes.FLAG then
result[value] = true
else
result[key] = value
end
end
end
---@param args string|string[]
---@param strict_checking boolean
---@return neotree.command.Parser.Parsed parsed_args
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

View file

@ -0,0 +1,745 @@
---@type neotree.Config.Base
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
default_source = "filesystem", -- you can choose a specific source `last` here which indicates the last used source
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.
enable_cursor_hijack = false, -- If enabled neotree will keep the cursor on the first letter of the filename when moving in the tree.
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 ':lua require("neo-tree").show_logs()' 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", "edgy" }, -- when opening files, do not use windows containing these filetypes or buftypes
open_files_using_relative_paths = false,
-- 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
popup_border_style = "NC", -- "double", "rounded", "single", "solid", (or "" to use 'winborder' on Neovim v0.11+)
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.command").execute({ action = "close" })
-- 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",
provider = function(icon, node, state) -- default icon provider utilizes nvim-web-devicons if available
if node.type == "file" or node.type == "terminal" then
local success, web_devicons = pcall(require, "nvim-web-devicons")
local name = node.type == "terminal" and "terminal" or node.name
if success then
local devicon, hl = web_devicons.get_icon(name)
icon.text = devicon or icon.text
icon.highlight = hl or icon.highlight
end
end
end
},
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",
},
-- If you don't want to use these columns, you can set `enabled = false` for each of them individually
file_size = {
enabled = true,
width = 12, -- width of the column
required_width = 64, -- min width of window required to show this column
},
type = {
enabled = true,
width = 10, -- width of the column
required_width = 110, -- min width of window required to show this column
},
last_modified = {
enabled = true,
width = 20, -- width of the column
required_width = 88, -- min width of window required to show this column
format = "%Y-%m-%d %I:%M %p", -- format string for timestamp (see `:h os.date()`)
-- or use a function that takes in the date in seconds and returns a string to display
--format = require("neo-tree.utils").relative_date, -- enable relative timestamps
},
created = {
enabled = false,
width = 20, -- width of the column
required_width = 120, -- min width of window required to show this column
format = "%Y-%m-%d %I:%M %p", -- format string for timestamp (see `:h os.date()`)
-- or use a function that takes in the date in seconds and returns a string to display
--format = require("neo-tree.utils").relative_date, -- enable relative timestamps
},
symlink_target = {
enabled = false,
text_format = " ➛ %s", -- %s will be replaced with the symlink target's path.
},
},
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 = 10, align = "right", hide_when_expanded = true },
{ "file_size", zindex = 10, align = "right" },
{ "type", zindex = 10, align = "right" },
{ "last_modified", zindex = 10, align = "right" },
{ "created", zindex = 10, align = "right" },
},
},
},
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 = 10, align = "right" },
{ "file_size", zindex = 10, align = "right" },
{ "type", zindex = 10, align = "right" },
{ "last_modified", zindex = 10, align = "right" },
{ "created", zindex = 10, 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") | ["<C-c>"] = "hello" | hello = function()
-- end | } | print("Hello world in filesystem")
-- } | } | end
--
-- see `:h neo-tree-custom-commands-global`
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
title = function (state) -- format the text that appears at the top of a popup window
return "Neo-tree " .. state.name:gsub("^%l", string.upper)
end,
-- you can also specify border here, if you want a different setting from
-- the global popup_border_style.
},
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 = {
["<space>"] = {
"toggle_node",
nowait = false, -- disable `nowait` if you have existing combos starting with this char that you want to use
},
["<2-LeftMouse>"] = "open",
["<cr>"] = "open",
-- ["<cr>"] = { "open", config = { expand_nested_files = true } }, -- expand nested file takes precedence
["<esc>"] = "cancel", -- close preview or floating neo-tree window
["P"] = {
"toggle_preview",
config = {
use_float = true,
use_snacks_image = true,
use_image_nvim = true,
-- title = "Neo-tree Preview", -- You can define a custom title for the preview floating window.
}
},
["<C-f>"] = { "scroll_preview", config = {direction = -10} },
["<C-b>"] = { "scroll_preview", config = {direction = 10} },
["l"] = "focus_preview",
["S"] = "open_split",
-- ["S"] = "split_with_window_picker",
["s"] = "open_vsplit",
-- ["sr"] = "open_rightbelow_vs",
-- ["sl"] = "open_leftabove_vs",
-- ["s"] = "vsplit_with_window_picker",
["t"] = "open_tabnew",
-- ["<cr>"] = "open_drop",
-- ["t"] = "open_tab_drop",
["w"] = "open_with_window_picker",
["C"] = "close_node",
--["C"] = "close_all_subnodes",
["z"] = "close_all_nodes",
--["Z"] = "expand_all_nodes",
--["Z"] = "expand_all_subnodes",
["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",
--["/"] = {"fuzzy_finder", config = { keep_filter_on_submit = true }},
--["/"] = "filter_as_you_type", -- this was the default until v1.28
["D"] = "fuzzy_finder_directory",
-- ["D"] = "fuzzy_sorter_directory",
["#"] = "fuzzy_sorter", -- fuzzy sorting using the fzy algorithm
["f"] = "filter_on_submit",
["<C-x>"] = "clear_filter",
["<bs>"] = "navigate_up",
["."] = "set_root",
["[g"] = "prev_git_modified",
["]g"] = "next_git_modified",
["i"] = "show_file_details", -- see `:h neo-tree-file-actions` for options to customize the window.
["b"] = "rename_basename",
["o"] = { "show_help", nowait=false, config = { title = "Order by", prefix_key = "o" }},
["oc"] = { "order_by_created", nowait = false },
["od"] = { "order_by_diagnostics", nowait = false },
["og"] = { "order_by_git_status", nowait = false },
["om"] = { "order_by_modified", nowait = false },
["on"] = { "order_by_name", nowait = false },
["os"] = { "order_by_size", nowait = false },
["ot"] = { "order_by_type", nowait = false },
},
fuzzy_finder_mappings = { -- define keymaps for filter popup window in fuzzy_finder_mode
["<down>"] = "move_cursor_down",
["<C-n>"] = "move_cursor_down",
["<up>"] = "move_cursor_up",
["<C-p>"] = "move_cursor_up",
["<Esc>"] = "close",
["<S-CR>"] = "close_keep_filter",
["<C-CR>"] = "close_clear_filter",
["<C-w>"] = { "<C-S-w>", raw = true },
{
-- normal mode mappings
n = {
["j"] = "move_cursor_down",
["k"] = "move_cursor_up",
["<S-CR>"] = "close_keep_filter",
["<C-CR>"] = "close_clear_filter",
["<esc>"] = "close",
}
}
-- ["<esc>"] = "noop", -- if you want to use normal mode
-- ["key"] = function(state, scroll_padding) ... end,
},
},
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
},
check_gitignore_in_search = true, -- check gitignore status for files/directories when searching
-- setting this to false will speed up searches, but gitignored
-- items won't be marked if they are visible.
-- 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
children_inherit_highlights = true, -- whether children of filtered parents should inherit their parent's highlight group
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",
},
always_show_by_pattern = { -- uses glob style patterns
--".env*",
},
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 = {
enabled = false, -- This will find and focus the file in the active buffer every time
-- -- the current file is changed while the tree is open.
leave_dirs_open = false, -- `false` closes auto expanded dirs, such as with `:Neotree reveal`
},
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 = {
enabled = true, -- This will find and focus the file in the active buffer every time
-- -- the current file is changed while the tree is open.
leave_dirs_open = false, -- `false` closes auto expanded dirs, such as with `:Neotree reveal`
},
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.
terminals_first = false, -- when true, terminals will be listed before file buffers
window = {
mappings = {
["<bs>"] = "navigate_up",
["."] = "set_root",
["d"] = "buffer_delete",
["bd"] = "buffer_delete",
["i"] = "show_file_details", -- see `:h neo-tree-file-actions` for options to customize the window.
["b"] = "rename_basename",
["o"] = { "show_help", nowait=false, config = { title = "Order by", prefix_key = "o" }},
["oc"] = { "order_by_created", nowait = false },
["od"] = { "order_by_diagnostics", nowait = false },
["om"] = { "order_by_modified", nowait = false },
["on"] = { "order_by_name", nowait = false },
["os"] = { "order_by_size", nowait = false },
["ot"] = { "order_by_type", nowait = false },
},
},
},
git_status = {
window = {
mappings = {
["A"] = "git_add_all",
["gu"] = "git_unstage_file",
["gU"] = "git_undo_last_commit",
["ga"] = "git_add_file",
["gr"] = "git_revert_file",
["gc"] = "git_commit",
["gp"] = "git_push",
["gg"] = "git_commit_and_push",
["i"] = "show_file_details", -- see `:h neo-tree-file-actions` for options to customize the window.
["b"] = "rename_basename",
["o"] = { "show_help", nowait=false, config = { title = "Order by", prefix_key = "o" }},
["oc"] = { "order_by_created", nowait = false },
["od"] = { "order_by_diagnostics", nowait = false },
["om"] = { "order_by_modified", nowait = false },
["on"] = { "order_by_name", nowait = false },
["os"] = { "order_by_size", nowait = false },
["ot"] = { "order_by_type", nowait = false },
},
},
},
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 = {
["<cr>"] = "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 = {
["<cr>"] = "toggle_node",
["<C-e>"] = "example_command",
["d"] = "show_debug_info",
},
},
},
}
return config

View file

@ -0,0 +1,110 @@
local q = require("neo-tree.events.queue")
local log = require("neo-tree.log")
local utils = require("neo-tree.utils")
---@class neotree.event.Functions
local M = {
-- Well known event names, you can make up your own
AFTER_RENDER = "after_render",
BEFORE_FILE_ADD = "before_file_add",
BEFORE_FILE_DELETE = "before_file_delete",
BEFORE_FILE_MOVE = "before_file_move",
BEFORE_FILE_RENAME = "before_file_rename",
BEFORE_RENDER = "before_render",
FILE_ADDED = "file_added",
FILE_DELETED = "file_deleted",
FILE_MOVED = "file_moved",
FILE_OPENED = "file_opened",
FILE_OPEN_REQUESTED = "file_open_requested",
FILE_RENAMED = "file_renamed",
FS_EVENT = "fs_event",
GIT_EVENT = "git_event",
GIT_STATUS_CHANGED = "git_status_changed",
STATE_CREATED = "state_created",
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_POPUP_INPUT_READY = "neo_tree_popup_input_ready",
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",
NEO_TREE_PREVIEW_BEFORE_RENDER = "neo_tree_preview_before_render",
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",
}
---@param autocmds string
---@return string event
---@return string? pattern
local parse_autocmd_string = function(autocmds)
local parsed = vim.split(autocmds, " ")
return parsed[1], parsed[2]
end
---@param event_name neotree.EventName|string
---@param autocmds string[]
---@param debounce_frequency integer?
---@param seed_fn function?
---@param nested boolean?
M.define_autocmd_event = function(event_name, autocmds, debounce_frequency, seed_fn, nested)
log.debug("Defining autocmd event: %s", event_name)
local augroup_name = "NeoTreeEvent_" .. event_name
q.define_event(event_name, {
setup = function()
local augroup = vim.api.nvim_create_augroup(augroup_name, { clear = false })
for _, autocmd in ipairs(autocmds) do
local event, pattern = parse_autocmd_string(autocmd)
log.trace("Registering autocmds on %s %s", event, pattern or "")
vim.api.nvim_create_autocmd({ event }, {
pattern = pattern or "*",
group = augroup,
nested = nested,
callback = function(args)
---@class neotree.event.Autocmd.CallbackArgs : neotree._vim.api.keyset.create_autocmd.callback_args
---@field afile string
local event_args = args --[[@as neotree._vim.api.keyset.create_autocmd.callback_args]]
event_args.afile = args.file or ""
M.fire_event(event_name, event_args)
end,
})
end
end,
seed = seed_fn,
teardown = function()
log.trace("Teardown autocmds for ", event_name)
vim.api.nvim_create_augroup(augroup_name, { clear = true })
end,
debounce_frequency = debounce_frequency,
debounce_strategy = utils.debounce_strategy.CALL_LAST_ONLY,
})
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

View file

@ -0,0 +1,167 @@
local utils = require("neo-tree.utils")
local log = require("neo-tree.log")
local Queue = require("neo-tree.collections").Queue
---@type table<string, neotree.collections.Queue?>
local event_queues = {}
---@type table <string, neotree.event.Definition?>
local event_definitions = {}
local M = {}
---@class neotree.event.Handler.Result
---@field handled boolean?
---@class neotree.event.Handler
---@field event neotree.EventName|string
---@field handler fun(table?):(neotree.event.Handler.Result?)
---@field id string?
local typecheck = require("neo-tree.health.typecheck")
local validate = typecheck.validate
---@param event_handler neotree.event.Handler
local validate_event_handler = function(event_handler)
return validate("event_handler", event_handler, function(eh)
validate("event", eh.event, "string")
validate("handler", eh.handler, "function")
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
---@class neotree.event.Definition
---@field teardown function?
---@field setup function?
---@field setup_was_run boolean?
---@param event_name neotree.EventName|string
---@param opts neotree.event.Definition
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
---@param event_name neotree.EventName|string
---@return boolean existed_and_destroyed
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
---@param event neotree.EventName|string
---@param args table
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
---@param event neotree.EventName|string
---@param args any?
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
---@param event_handler neotree.event.Handler
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
---@param event_handler neotree.event.Handler
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

View file

@ -0,0 +1,183 @@
local Job = require("plenary.job")
local uv = vim.uv or vim.loop
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
---@param ignored string[]
---@param path string
---@param _type neotree.Filetype
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
---@param state neotree.State
---@param items neotree.FileItem[]
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 = uv.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_gitignored = state.filtered_items and state.filtered_items.hide_gitignored == false
log.trace("IGNORED: Comparing results to mark items as ignored:", show_gitignored)
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_gitignored = show_gitignored
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 running_jobs = 0
local job_count = 0
local completed_jobs = 0
-- This is called when a job completes, and starts the next job if there are any left
-- or calls the callback if all jobs are complete.
-- It is also called once at the start to start the first 50 jobs.
--
-- This is done to avoid running too many jobs at once, which can cause a crash from
-- having too many open files.
local run_more_jobs = function()
while #jobs > 0 and running_jobs < 50 and job_count > completed_jobs do
local next_job = table.remove(jobs, #jobs)
next_job:start()
running_jobs = running_jobs + 1
end
if completed_jobs == job_count then
finalize(all_results)
callback(all_results)
end
end
for folder, folder_items in pairs(folders) do
local args = { "-C", folder, "check-ignore", "--stdin" }
---@diagnostic disable-next-line: missing-fields
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", folder, ":", self:stderr_result())
result = {}
else
result = self:result()
end
vim.list_extend(all_results, process_result(result))
running_jobs = running_jobs - 1
completed_jobs = completed_jobs + 1
run_more_jobs()
end,
})
table.insert(jobs, job)
job_count = job_count + 1
end
run_more_jobs()
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

View file

@ -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

View file

@ -0,0 +1,350 @@
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
---@class (exact) neotree.git.Context
---@field git_status neotree.git.Status
---@field git_root string
---@field exclude_directories boolean
---@field lines_parsed integer
---@alias neotree.git.Status table<string, string>
---@param context neotree.git.Context
local parse_git_status_line = function(context, line)
context.lines_parsed = context.lines_parsed + 1
if type(line) ~= "string" then
return
end
if #line < 3 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 neotree.git.Status, string? git_status the neotree.Git.Status of the given root
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
---@type neotree.git.Context
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()
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()
---@diagnostic disable-next-line: missing-fields
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 = 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,
})
---@diagnostic disable-next-line: missing-fields
local unstaged_job = Job:new({
command = "git",
args = { "-C", git_root, "diff", "--name-status" },
enable_recording = false,
maximium_results = context.max_lines,
on_stdout = 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,
})
---@diagnostic disable-next-line: missing-fields
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 = 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,
})
---@diagnostic disable-next-line: missing-fields
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

View file

@ -0,0 +1,66 @@
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
---@diagnostic disable-next-line: missing-fields
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_output = utils.execute_command({ "git", unpack(args) })
if not ok then
log.trace("GIT ROOT ERROR ", git_output)
return nil
end
local git_root = git_output[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

View file

@ -0,0 +1,326 @@
local typecheck = require("neo-tree.health.typecheck")
local M = {}
local health = vim.health
local function check_dependencies()
local devicons_ok = pcall(require, "nvim-web-devicons")
if devicons_ok then
health.ok("nvim-web-devicons is installed")
else
health.info("nvim-web-devicons not installed")
end
local plenary_ok = pcall(require, "plenary")
if plenary_ok then
health.ok("plenary.nvim is installed")
else
health.error("plenary.nvim is not installed")
end
local nui_ok = pcall(require, "nui.tree")
if nui_ok then
health.ok("nui.nvim is installed")
else
health.error("nui.nvim not installed")
end
health.info("Optional dependencies for preview image support (only need one):")
-- optional
local snacks_ok = pcall(require, "snacks.image")
if snacks_ok then
health.ok("snacks.image is installed")
else
health.info("nui.nvim not installed")
end
local image_ok = pcall(require, "image")
if image_ok then
health.ok("image.nvim is installed")
else
health.info("nui.nvim not installed")
end
end
local validate = typecheck.validate
---@module "neo-tree.types.config"
---@param config neotree.Config.Base
function M.check_config(config)
---@type [string, string?][]
local errors = {}
local start = vim.uv.hrtime()
local verbose = vim.o.verbose > 0
local matched, missed = validate(
"config",
config,
function(cfg)
---@class neotree.health.Validator.Generators
local v = {
array = function(validator)
---@generic T
---@param arr T[]
return function(arr)
for i, val in ipairs(arr) do
validate(("[%d]"):format(i), val, validator)
end
end
end,
literal = function(literals)
return function(value)
return vim.tbl_contains(literals, value),
("value %s did not match literals %s"):format(value, table.concat(literals, "|"))
end
end,
}
local schema = {
Filesystem = {
---@param follow_current_file neotree.Config.Filesystem.FollowCurrentFile
FollowCurrentFile = function(follow_current_file)
validate("enabled", follow_current_file.enabled, "boolean", true)
validate("leave_dirs_open", follow_current_file.leave_dirs_open, "boolean", true)
end,
},
---@param window neotree.Config.Window
Window = function(window)
validate("mappings", window.mappings, "table") -- TODO: More specific validation for mappings table
end,
SourceSelector = {
---@param item neotree.Config.SourceSelector.Item
Item = function(item)
validate("source", item.source, "string")
validate("padding", item.padding, { "number", "table" }, true) -- TODO: More specific validation for padding table
validate("separator", item.separator, { "string", "table" }, true) -- TODO: More specific validation for separator table
end,
---@param sep neotree.Config.SourceSelector.Separator
Separator = function(sep)
validate("left", sep.left, "string")
validate("right", sep.right, "string")
validate("override", sep.override, v.literal({ "right", "left", "active" }), true)
end,
},
Renderers = v.array("table"),
}
if not validate("config", cfg, "table") then
health.error("Config does not exist")
return
end
validate("sources", cfg.sources, v.array("string"), false)
validate("add_blank_line_at_top", cfg.add_blank_line_at_top, "boolean")
validate("auto_clean_after_session_restore", cfg.auto_clean_after_session_restore, "boolean")
validate("close_if_last_window", cfg.close_if_last_window, "boolean")
validate("default_source", cfg.default_source, "string")
validate("enable_diagnostics", cfg.enable_diagnostics, "boolean")
validate("enable_git_status", cfg.enable_git_status, "boolean")
validate("enable_modified_markers", cfg.enable_modified_markers, "boolean")
validate("enable_opened_markers", cfg.enable_opened_markers, "boolean")
validate("enable_refresh_on_write", cfg.enable_refresh_on_write, "boolean")
validate("enable_cursor_hijack", cfg.enable_cursor_hijack, "boolean")
validate("git_status_async", cfg.git_status_async, "boolean")
validate("git_status_async_options", cfg.git_status_async_options, function(options)
validate("batch_size", options.batch_size, "number")
validate("batch_delay", options.batch_delay, "number")
validate("max_lines", options.max_lines, "number")
end)
validate("hide_root_node", cfg.hide_root_node, "boolean")
validate("retain_hidden_root_indent", cfg.retain_hidden_root_indent, "boolean")
validate(
"log_level",
cfg.log_level,
v.literal({ "trace", "debug", "info", "warn", "error", "fatal" }),
true
)
validate("log_to_file", cfg.log_to_file, { "boolean", "string" })
validate("open_files_in_last_window", cfg.open_files_in_last_window, "boolean")
validate(
"open_files_do_not_replace_types",
cfg.open_files_do_not_replace_types,
v.array("string")
)
validate("open_files_using_relative_paths", cfg.open_files_using_relative_paths, "boolean")
validate(
"popup_border_style",
cfg.popup_border_style,
v.literal({ "NC", "rounded", "single", "solid", "double", "" })
)
validate("resize_timer_interval", cfg.resize_timer_interval, "number")
validate("sort_case_insensitive", cfg.sort_case_insensitive, "boolean")
validate("sort_function", cfg.sort_function, "function", true)
validate("use_popups_for_input", cfg.use_popups_for_input, "boolean")
validate("use_default_mappings", cfg.use_default_mappings, "boolean")
validate("source_selector", cfg.source_selector, function(ss)
validate("winbar", ss.winbar, "boolean")
validate("statusline", ss.statusline, "boolean")
validate("show_scrolled_off_parent_node", ss.show_scrolled_off_parent_node, "boolean")
validate("sources", ss.sources, v.array(schema.SourceSelector.Item))
validate("content_layout", ss.content_layout, v.literal({ "start", "end", "center" }))
validate(
"tabs_layout",
ss.tabs_layout,
v.literal({ "equal", "start", "end", "center", "focus" })
)
validate("truncation_character", ss.truncation_character, "string", false)
validate("tabs_min_width", ss.tabs_min_width, "number", true)
validate("tabs_max_width", ss.tabs_max_width, "number", true)
validate("padding", ss.padding, { "number", "table" }) -- TODO: More specific validation for padding table
validate("separator", ss.separator, schema.SourceSelector.Separator)
validate("separator_active", ss.separator_active, schema.SourceSelector.Separator, true)
validate("show_separator_on_edge", ss.show_separator_on_edge, "boolean")
validate("highlight_tab", ss.highlight_tab, "string")
validate("highlight_tab_active", ss.highlight_tab_active, "string")
validate("highlight_background", ss.highlight_background, "string")
validate("highlight_separator", ss.highlight_separator, "string")
validate("highlight_separator_active", ss.highlight_separator_active, "string")
end)
validate("event_handlers", cfg.event_handlers, v.array("table"), true) -- TODO: More specific validation for event handlers
validate("default_component_configs", cfg.default_component_configs, function(defaults)
validate("container", defaults.container, "table") -- TODO: More specific validation
validate("indent", defaults.indent, "table") -- TODO: More specific validation
validate("icon", defaults.icon, "table") -- TODO: More specific validation
validate("modified", defaults.modified, "table") -- TODO: More specific validation
validate("name", defaults.name, "table") -- TODO: More specific validation
validate("git_status", defaults.git_status, "table") -- TODO: More specific validation
validate("file_size", defaults.file_size, "table") -- TODO: More specific validation
validate("type", defaults.type, "table") -- TODO: More specific validation
validate("last_modified", defaults.last_modified, "table") -- TODO: More specific validation
validate("created", defaults.created, "table") -- TODO: More specific validation
validate("symlink_target", defaults.symlink_target, "table") -- TODO: More specific validation
end)
validate("renderers", cfg.renderers, schema.Renderers)
validate("nesting_rules", cfg.nesting_rules, v.array("table"), true) -- TODO: More specific validation for nesting rules
validate("commands", cfg.commands, "table", true) -- TODO: More specific validation for commands
validate("window", cfg.window, function(window)
validate("position", window.position, "string") -- TODO: More specific validation
validate("width", window.width, "number")
validate("height", window.height, "number")
validate("auto_expand_width", window.auto_expand_width, "boolean")
validate("popup", window.popup, function(popup)
validate("title", popup.title, "function")
validate("size", popup.size, function(size)
validate("height", size.height, { "string", "number" })
validate("width", size.width, { "string", "number" })
end)
validate(
"border",
popup.border,
v.literal({ "NC", "rounded", "single", "solid", "double", "" }),
true
)
end)
validate("insert_as", window.insert_as, v.literal({ "child", "sibling" }), true)
validate("mapping_options", window.mapping_options, "table") -- TODO: More specific validation
validate("mappings", window.mappings, v.array("table")) -- TODO: More specific validation for mapping items
end)
validate("filesystem", cfg.filesystem, function(fs)
validate(
"async_directory_scan",
fs.async_directory_scan,
v.literal({ "auto", "always", "never" })
)
validate("scan_mode", fs.scan_mode, v.literal({ "shallow", "deep" }))
validate("bind_to_cwd", fs.bind_to_cwd, "boolean")
validate("cwd_target", fs.cwd_target, function(cwd_target)
validate("sidebar", cwd_target.sidebar, v.literal({ "tab", "window", "global" }))
validate("current", cwd_target.current, v.literal({ "tab", "window", "global" }))
end)
validate("check_gitignore_in_search", fs.check_gitignore_in_search, "boolean")
validate("filtered_items", fs.filtered_items, function(f)
validate("visible", f.visible, "boolean")
validate("force_visible_in_empty_folder", f.force_visible_in_empty_folder, "boolean")
validate("children_inherit_highlights", f.children_inherit_highlights, "boolean")
validate("show_hidden_count", f.show_hidden_count, "boolean")
validate("hide_dotfiles", f.hide_dotfiles, "boolean")
validate("hide_gitignored", f.hide_gitignored, "boolean")
validate("hide_hidden", f.hide_hidden, "boolean")
validate("hide_by_name", f.hide_by_name, v.array("string"))
validate("hide_by_pattern", f.hide_by_pattern, v.array("string"))
validate("always_show", f.always_show, v.array("string"))
validate("always_show_by_pattern", f.always_show_by_pattern, v.array("string"))
validate("never_show", f.never_show, v.array("string"))
validate("never_show_by_pattern", f.never_show_by_pattern, v.array("string"))
end)
validate("find_by_full_path_words", fs.find_by_full_path_words, "boolean")
validate("find_command", fs.find_command, "string", true)
validate("find_args", fs.find_args, { "table", "function" }, true)
validate("group_empty_dirs", fs.group_empty_dirs, "boolean")
validate("search_limit", fs.search_limit, "number")
validate("follow_current_file", fs.follow_current_file, schema.Filesystem.FollowCurrentFile)
validate(
"hijack_netrw_behavior",
fs.hijack_netrw_behavior,
v.literal({ "open_default", "open_current", "disabled" }),
true
)
validate("use_libuv_file_watcher", fs.use_libuv_file_watcher, "boolean")
validate("renderers", fs.renderers, schema.Renderers)
validate("window", fs.window, function(window)
validate("mappings", window.mappings, "table") -- TODO: More specific validation for mappings table
validate("fuzzy_finder_mappings", window.fuzzy_finder_mappings, "table") -- TODO: More specific validation
end)
end)
validate("buffers", cfg.buffers, function(buffers)
validate("bind_to_cwd", buffers.bind_to_cwd, "boolean")
validate(
"follow_current_file",
buffers.follow_current_file,
schema.Filesystem.FollowCurrentFile
)
validate("group_empty_dirs", buffers.group_empty_dirs, "boolean")
validate("show_unloaded", buffers.show_unloaded, "boolean")
validate("terminals_first", buffers.terminals_first, "boolean")
validate("renderers", buffers.renderers, schema.Renderers)
validate("window", buffers.window, schema.Window)
end)
validate("git_status", cfg.git_status, function(git_status)
validate("renderers", git_status.renderers, schema.Renderers)
validate("window", git_status.window, schema.Window)
end)
validate("document_symbols", cfg.document_symbols, function(ds)
validate("follow_cursor", ds.follow_cursor, "boolean")
validate("client_filters", ds.client_filters, { "string", "table" }) -- TODO: More specific validation
validate("custom_kinds", ds.custom_kinds, "table") -- TODO: More specific validation
validate("kinds", ds.kinds, "table")
validate("renderers", ds.renderers, schema.Renderers)
validate("window", ds.window, schema.Window)
end)
end,
false,
nil,
function(err)
errors[#errors + 1] = { err }
end,
true
)
local _end = vim.uv.hrtime()
if #errors == 0 then
health.ok("Configuration conforms to the neotree.Config.Base schema")
else
for _, err in ipairs(errors) do
health.error(unpack(err))
end
end
if verbose then
health.info(
"[verbose] Config schema checking is not comprehensive yet, unchecked keys listed below:"
)
if missed then
for _, miss in ipairs(missed) do
health.info(miss)
end
end
end
end
function M.check()
health.start("Dependencies")
check_dependencies()
health.start("Configuration")
local config = require("neo-tree").ensure_config()
M.check_config(config)
end
return M

View file

@ -0,0 +1,213 @@
local M = {}
---Like type() but also supports "callable" like neovim does.
---@see _G.type
---@param obj any
---@param expected neotree.LuaType
function M.match(obj, expected)
if type(obj) == expected then
return true
end
if expected == "callable" and vim.is_callable(obj) then
return true
end
return false
end
---@alias neotree.LuaType type|"callable"
---@alias neotree.health.ValidatorFunction<T> fun(value: T):boolean?,string?
---@alias neotree.health.Validator<T> elem_or_list<neotree.LuaType>|neotree.health.ValidatorFunction<T>
---@type (fun(err:string))[]
M.errfuncs = {}
---@type string[]
M.namestack = {}
---@generic T : table
---@param path string
---@param tbl T
---@param accesses string[]
---@param missed_paths table<string, true?>
---@return T mocked_tbl
local function mock_recursive(path, tbl, accesses, missed_paths, track_missed)
local mock_table = {}
---@class neotree.health.Mock.Metatable<T> : metatable
---@field accesses string[]
local mt = {
__original_table = tbl,
accesses = accesses,
}
---@return string[] missed_paths
mt.get_missed_paths = function()
---@type string[]
local missed_list = {}
if track_missed then
for p, _ in pairs(missed_paths) do
table.insert(missed_list, p)
end
end
table.sort(missed_list)
return missed_list
end
mt.__index = function(_, key)
local path_segment
if type(key) == "number" then
path_segment = ("[%02d]"):format(key)
else
path_segment = tostring(key)
end
local full_path
if path == "" then
full_path = path_segment
elseif type(key) == "number" then
full_path = path .. path_segment
else
full_path = path .. "." .. path_segment
end
-- Track accesses and missed accesses
mt.accesses[#mt.accesses + 1] = full_path
if track_missed then
missed_paths[full_path] = nil
end
local value = mt.__original_table[key]
if type(value) == "table" then
return mock_recursive(full_path, value, mt.accesses, missed_paths, track_missed)
end
return value
end
setmetatable(mock_table, mt)
return mock_table
end
--- Wraps a given table in a special mock table that tracks all accesses
--- (reads) to its fields and sub-fields. Optionally tracks unaccessed fields.
---
---@generic T : table
---@param name string The base name for the table, this forms the root of the access paths.
---@param tbl T The table to be mocked.
---@param track_missed boolean? Track which fields were NOT accessed.
---@return T mocked
function M.mock(name, tbl, track_missed)
local accesses = {}
local path_set = {}
track_missed = track_missed or false
if track_missed then
-- Generate another mock table and fully traverse that one first
local root_mock = M.mock(name, tbl, false)
---@param current_table table
local function deep_traverse_mock(current_table)
---@type neotree.health.Mock.Metatable
local mt = getmetatable(current_table)
for k, v in pairs(mt.__original_table) do
if type(v) == "table" then
deep_traverse_mock(current_table[k])
else
mt.__index(nil, k)
end
end
end
deep_traverse_mock(root_mock)
accesses = getmetatable(root_mock).accesses
for _, path in ipairs(accesses) do
path_set[path] = true
end
end
-- Start the recursive mocking process, passing all necessary shared tracking data.
return mock_recursive(name, tbl, accesses, path_set, track_missed)
end
---A comprehensive version of vim.validate that makes it easy to validate nested tables of various types
---@generic T
---@param name string
---@param value T
---@param validator neotree.health.Validator<T>
---@param optional? boolean Whether value can be nil
---@param message? string message when validation fails
---@param on_invalid? fun(err: string, value: T):boolean? What to do when a (nested) validation fails, return true to throw error
---@param track_missed? boolean Whether to return a second table that contains every non-checked field
---@return boolean valid
---@return string[]? missed
function M.validate(name, value, validator, optional, message, on_invalid, track_missed)
local matched, errmsg, errinfo
M.namestack[#M.namestack + 1] = name
if type(validator) == "string" then
matched = M.match(value, validator)
elseif type(validator) == "table" then
for _, v in ipairs(validator) do
matched = M.match(value, v)
if matched then
break
end
end
elseif type(validator) == "function" and value ~= nil then
local ok = false
if on_invalid then
M.errfuncs[#M.errfuncs + 1] = on_invalid
end
if track_missed and type(value) == "table" then
value = M.mock(name, value, true)
end
ok, matched, errinfo = pcall(validator, value)
if on_invalid then
M.errfuncs[#M.errfuncs] = nil
end
if not ok then
errinfo = matched
matched = false
elseif matched == nil then
matched = true
end
end
matched = matched or (optional and value == nil) or false
if not matched then
---@type string
local expected
if vim.is_callable(validator) then
expected = "?"
else
---@cast validator -function
local expected_types = type(validator) == "string" and { validator } or validator
---@cast expected_types -string
if optional then
expected_types[#expected_types + 1] = "nil"
end
expected = table.concat(expected_types, "|")
end
errmsg = ("%s: %s, got %s"):format(
table.concat(M.namestack, "."),
message or ("expected " .. expected),
message and value or type(value)
)
if errinfo then
errmsg = errmsg .. ", Info: " .. errinfo
end
local errfunc = M.errfuncs[#M.errfuncs]
local should_error = not errfunc or errfunc(errmsg)
if should_error then
M.namestack[#M.namestack] = nil
error(errmsg, 2)
end
end
M.namestack[#M.namestack] = nil
if track_missed then
local missed = getmetatable(value).get_missed_paths()
return matched, missed
end
return matched
end
return M

View file

@ -0,0 +1,188 @@
-- 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.
-- 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.DEBUG },
{ 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
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
-- Ignore 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

View file

@ -0,0 +1,136 @@
local utils = require("neo-tree.utils")
local M = {}
local migrations = {}
M.show_migrations = function()
if #migrations > 0 then
local content = {}
for _, message in ipairs(migrations) do
vim.list_extend(content, vim.split("\n## " .. message, "\n", { trimempty = false }))
end
local header = "# Neo-tree configuration has been updated. Please review the changes below."
table.insert(content, 1, header)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, content)
vim.bo[buf].buftype = "nofile"
vim.bo[buf].bufhidden = "wipe"
vim.bo[buf].buflisted = false
vim.bo[buf].swapfile = false
vim.bo[buf].modifiable = false
vim.bo[buf].filetype = "markdown"
vim.api.nvim_buf_set_name(buf, "Neo-tree migrations")
vim.defer_fn(function()
vim.cmd(string.format("%ssplit", #content))
vim.api.nvim_win_set_buf(0, buf)
end, 100)
end
end
---@param config neotree.Config.Base
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, old, nil)
utils.set_value(config, new, existing)
migrations[#migrations + 1] =
string.format("The `%s` option has been deprecated, please use `%s` instead.", old, new)
end
end
local moved_inside = function(old, new_inside, converter)
local existing = utils.get_value(config, old)
if type(existing) ~= "nil" and type(existing) ~= "table" then
if type(converter) == "function" then
existing = converter(existing)
end
utils.set_value(config, old, {})
local new = old .. "." .. new_inside
utils.set_value(config, new, existing)
migrations[#migrations + 1] =
string.format("The `%s` option is replaced with a table, please move to `%s`.", old, new)
end
end
local removed = function(key, desc)
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.\n%s", key, desc or "")
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
moved_inside("filesystem.follow_current_file", "enabled")
moved_inside("buffers.follow_current_file", "enabled")
-- v3.x
removed("close_floats_on_escape_key")
-- v4.x
removed(
"enable_normal_mode_for_inputs",
[[
Please use `neo_tree_popup_input_ready` event instead and call `stopinsert` inside the handler.
<https://github.com/nvim-neo-tree/neo-tree.nvim/pull/1372>
See instructions in `:h neo-tree-events` for more details.
```lua
event_handlers = {
{
event = "neo_tree_popup_input_ready",
---@param args { bufnr: integer, winid: integer }
handler = function(args)
vim.cmd("stopinsert")
vim.keymap.set("i", "<esc>", vim.cmd.stopinsert, { noremap = true, buffer = args.bufnr })
end,
}
}
```
]]
)
return migrations
end
return M

View file

@ -0,0 +1,715 @@
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 hijack_cursor = require("neo-tree.sources.common.hijack_cursor")
local M = {}
---@param source_config { window: {mappings: neotree.Config.Window.Mappings} }
local normalize_mappings = function(source_config)
if source_config == nil then
return
end
local mappings = vim.tbl_get(source_config, { "window", "mappings" })
if mappings then
local fixed = mapping_helper.normalize_mappings(mappings)
source_config.window.mappings = fixed --[[@as neotree.Config.Window.Mappings]]
end
end
---@param source_config neotree.Config.Filesystem
local normalize_fuzzy_mappings = function(source_config)
if source_config == nil then
return
end
local mappings = source_config.window and source_config.window.fuzzy_finder_mappings
if mappings then
local fixed = mapping_helper.normalize_mappings(mappings)
source_config.window.fuzzy_finder_mappings = fixed --[[@as neotree.Config.FuzzyFinder.Mappings]]
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_CHANGED, { "BufWritePost" }, 200)
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
-- 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 prior_type = vim.bo[prior_buf].filetype
-- there is nothing more we want to do with floating windows
-- but when prior_type is neo-tree we might need to redirect buffer somewhere else.
if utils.is_floating() and prior_type ~= "neo-tree" then
return
end
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#")
local win_width = vim.api.nvim_win_get_width(current_winid)
-- 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.
---@diagnostic disable-next-line: param-type-mismatch
pcall(vim.cmd, "bdelete " .. bufname)
local fake_state = {
window = {
position = position,
width = win_width or M.config.window.width,
},
}
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 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) --[[@as neotree.sources.filesystem.State]]
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
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
---@param user_config neotree.Config?
---@return neotree.Config.Base full_config
M.merge_config = function(user_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")
if events_setup then
events.clear_all_events()
end
define_events()
-- Prevent netrw hijacking lazy-loading from conflicting with normal hijacking.
vim.g.neotree_watching_bufenter = 1
-- Prevent accidentally opening another file in the neo-tree window.
events.subscribe({
event = events.VIM_BUFFER_ENTER,
handler = M.buffer_enter_event,
})
events.subscribe({
event = events.NEO_TREE_WINDOW_AFTER_OPEN,
handler = function(args)
if not vim.w[args.winid].neo_tree_settings_applied then
-- TODO: should figure out a less disorganized way to set window options
-- BufEnter doesn't trigger while vim is starting up so this will handle it instead.
M.buffer_enter_event()
end
end,
})
-- 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 or {}) 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)
normalize_fuzzy_mappings(default_config.filesystem)
normalize_fuzzy_mappings(user_config.filesystem)
if user_config.use_default_mappings == false then
default_config.filesystem.window.fuzzy_finder_mappings = {}
end
-- 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
-- local 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 and M.config.default_source ~= "last" 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
---@type neotree.Config.HijackNetrwBehavior[]
local disable_netrw_values = { "open_default", "open_current" }
local hijack_behavior = M.config.filesystem.hijack_netrw_behavior
if vim.tbl_contains(disable_netrw_values, hijack_behavior) then
-- Disable netrw autocmds
vim.cmd("silent! autocmd! FileExplorer *")
elseif hijack_behavior ~= "disabled" then
require("neo-tree.log").error(
"Invalid value for filesystem.hijack_netrw_behavior: '"
.. hijack_behavior
.. "', will default to 'disabled'"
)
M.config.filesystem.hijack_netrw_behavior = "disabled"
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
if vim.tbl_contains(M.config.sources, ss_source.source) 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] --[[@as table]], M.config, module)
manager.redraw(source_name)
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 <afile> 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)
if not winid then
return
end
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
if M.config.enable_cursor_hijack then
hijack_cursor.setup()
end
return M.config
end
return M

View file

@ -0,0 +1,76 @@
local utils = require("neo-tree.utils")
local M = {}
---@param key string
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 == "<a" then
mod = "<m"
end
local alpha = parts[2]
if #alpha > 2 then
alpha = alpha:lower()
end
key = string.format("%s-%s", mod, alpha)
return key
else
key = key:lower()
if key == "<backspace>" then
return "<bs>"
elseif key == "<enter>" then
return "<cr>"
elseif key == "<return>" then
return "<cr>"
end
end
end
return key
end
---@class neotree.SimpleMappings
---@field [string] string|function?
---@class neotree.SimpleMappingsByMode
---@field [string] neotree.SimpleMappings?
---@class neotree.Mappings : neotree.SimpleMappings
---@field [integer] neotree.SimpleMappingsByMode?
---@param map neotree.Mappings
---@return neotree.Mappings new_map
M.normalize_mappings = function(map)
local new_map = M.normalize_simple_mappings(map)
---@cast new_map neotree.Mappings
for i, mappings_by_mode in ipairs(map) do
new_map[i] = {}
for mode, simple_mappings in pairs(mappings_by_mode) do
---@cast simple_mappings neotree.SimpleMappings
new_map[i][mode] = M.normalize_simple_mappings(simple_mappings)
end
end
return new_map
end
---@param map neotree.SimpleMappings
---@return neotree.SimpleMappings new_map
M.normalize_simple_mappings = function(map)
local new_map = {}
for key, value in pairs(map) do
if type(key) == "string" then
local normalized_key = M.normalize_map_key(key)
if normalized_key ~= nil then
new_map[normalized_key] = value
end
end
end
return new_map
end
return M

View file

@ -0,0 +1,102 @@
local uv = vim.uv or vim.loop
local nt = require("neo-tree")
local utils = require("neo-tree.utils")
local M = {}
local get_position = function(source_name)
local pos = utils.get_value(nt.config, source_name .. ".window.position", "left", true)
return pos
end
---@return neotree.Config.HijackNetrwBehavior
M.get_hijack_behavior = function()
nt.ensure_config()
return nt.config.filesystem.hijack_netrw_behavior
end
---@return boolean hijacked Whether the hijack was successful
M.hijack = function()
local hijack_behavior = M.get_hijack_behavior()
if hijack_behavior == "disabled" then
return false
end
-- ensure this is a directory
local dir_bufnr = vim.api.nvim_get_current_buf()
local path_to_hijack = vim.api.nvim_buf_get_name(dir_bufnr)
local stats = uv.fs_stat(path_to_hijack)
if not stats or 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 dir_window = vim.api.nvim_get_current_win()
-- Now actually open the tree, with a very quick debounce because this may be
-- called multiple times in quick succession.
utils.debounce("hijack_netrw_" .. dir_window, function()
local manager = require("neo-tree.sources.manager")
local log = require("neo-tree.log")
-- We will want to replace the "directory" buffer with either the "alternate"
-- buffer or a new blank one.
local replacement_buffer = vim.fn.bufnr("#")
local is_currently_neo_tree = false
if replacement_buffer > 0 then
if vim.bo[replacement_buffer].filetype == "neo-tree" then
-- don't hijack the current window if it's already a Neo-tree sidebar
local position = vim.b[replacement_buffer].neo_tree_position
if position == "current" then
replacement_buffer = -1
else
is_currently_neo_tree = true
end
end
end
if not should_open_current then
if replacement_buffer == dir_bufnr or replacement_buffer < 1 then
replacement_buffer = vim.api.nvim_create_buf(true, false)
log.trace("Created new buffer for netrw hijack", replacement_buffer)
end
end
if replacement_buffer > 0 then
log.trace("Replacing buffer in netrw hijack", replacement_buffer)
pcall(vim.api.nvim_win_set_buf, dir_window, replacement_buffer)
end
-- If a window takes focus (e.g. lazy.nvim installing plugins on startup) in the time between the method call and
-- this debounced callback, we should focus that window over neo-tree.
local current_window = vim.api.nvim_get_current_win()
local should_restore_cursor = current_window ~= dir_window
local cleanup = vim.schedule_wrap(function()
log.trace("Deleting buffer in netrw hijack", dir_bufnr)
pcall(vim.api.nvim_buf_delete, dir_bufnr, { force = true })
if should_restore_cursor then
vim.api.nvim_set_current_win(current_window)
end
end)
---@type neotree.sources.filesystem.State
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, dir_window) --[[@as neotree.sources.filesystem.State]]
state.current_position = "current"
elseif is_currently_neo_tree then
log.debug("hijack_netrw: opening in existing Neo-tree")
state = manager.get_state("filesystem") --[[@as neotree.sources.filesystem.State]]
else
log.debug("hijack_netrw: opening default")
manager.close_all_except("filesystem")
state = manager.get_state("filesystem") --[[@as neotree.sources.filesystem.State]]
end
require("neo-tree.sources.filesystem")._navigate_internal(state, path_to_hijack, nil, cleanup)
end, 10, utils.debounce_strategy.CALL_LAST_ONLY)
return true
end
return M

View file

@ -0,0 +1,100 @@
--This file should contain all commands meant to be used by mappings.
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")
---@class neotree.sources.Buffers.Commands : neotree.sources.Common.Commands
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
---@type neotree.TreeCommandVisual
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
---@type neotree.TreeCommandVisual
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 node = state.tree:get_node()
while node and node.type ~= "directory" do
local parent_id = node:get_parent_id()
node = parent_id and state.tree:get_node(parent_id) or nil
end
if not node then
return
end
buffers.navigate(state, node:get_id())
end
cc._add_common_commands(M)
return M

View file

@ -0,0 +1,58 @@
-- 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")
---@alias neotree.Component.Buffers._Key
---|"name"
---@class neotree.Component.Buffers
---@field [1] neotree.Component.Buffers._Key|neotree.Component.Common._Key
---@type table<neotree.Component.Buffers._Key, neotree.Renderer>
local M = {}
---@class (exact) neotree.Component.Buffers.Name : neotree.Component.Common.Name
---@param config neotree.Component.Buffers.Name
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)

View file

@ -0,0 +1,215 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
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")
---@class neotree.sources.Buffers : neotree.Source
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.enabled 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 state neotree.State
---@param path string? Path to navigate to. If empty, will navigate to the cwd.
---@param path_to_reveal string?
---@param callback function?
---@param async boolean?
M.navigate = function(state, path, path_to_reveal, callback, async)
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
if type(callback) == "function" then
vim.schedule(callback)
end
end
---@class neotree.Config.Buffers.Renderers : neotree.Config.Renderers
---@class (exact) neotree.Config.Buffers : neotree.Config.Source
---@field bind_to_cwd boolean?
---@field follow_current_file neotree.Config.Filesystem.FollowCurrentFile?
---@field group_empty_dirs boolean?
---@field show_unloaded boolean?
---@field terminals_first boolean?
---@field renderers neotree.Config.Buffers.Renderers?
---Configures the plugin, should be called before the plugin is used.
---@param config neotree.Config.Buffers Configuration table containing any keys that the user wants to change from the defaults. May be empty to accept default values.
---@param global_config neotree.Config.Base
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.STATE_CREATED,
handler = function(state)
state.diagnostics_lookup = utils.get_diagnostic_counts()
end,
})
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.enabled 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

View file

@ -0,0 +1,110 @@
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") --[[@as neotree.FileItem.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
-- make sure this is within the root path
if utils.is_subpath(state.path, 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
if state.terminals_first then
table.insert(root_folders, 1, terminal_root)
else
table.insert(root_folders, terminal_root)
end
end
state.default_expanded_nodes = {}
for id, _ in pairs(context.folders) do
table.insert(state.default_expanded_nodes, id)
end
file_items.advanced_sort(root.children, state)
renderer.show_nodes(root_folders, state)
state.loading = false
end
return M

View file

@ -0,0 +1,965 @@
--This file should contain all commands meant to be used by mappings.
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")
local async = require("plenary.async")
local node_expander = require("neo-tree.sources.common.node_expander")
---@alias neotree.TreeCommandNormal fun(state: neotree.StateWithTree, ...: any)
---@alias neotree.TreeCommandVisual fun(state: neotree.StateWithTree, selected_nodes: NuiTree.Node[], ...: any)
---@alias neotree.TreeCommand neotree.TreeCommandNormal|neotree.TreeCommandVisual
---Gets the node parent folder
---@param state neotree.StateWithTree
---@return NuiTree.Node? node
local function get_folder_node(state)
local tree = state.tree
local node = tree:get_node()
local last_id = assert(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 neotree.StateWithTree
---@return string root_path 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
---TODO
---@diagnostic disable-next-line: return-type-mismatch
return using_root_directory
end
---@class neotree.sources.Common.Commands
---@field [string] neotree.TreeCommand
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 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)
if not node then
return
end
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 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)
if not node then
return
end
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
---Expand all nodes
---@param node table? A single node to expand (defaults to all root nodes)
---@param prefetcher table? an object with two methods `prefetch(state, node)` and `should_prefetch(node) => boolean`
M.expand_all_nodes = function(state, node, prefetcher)
local root_nodes = node and { node } or state.tree:get_nodes()
renderer.position.set(state, nil)
local task = function()
for _, root in pairs(root_nodes) do
log.debug("Expanding all nodes under " .. root:get_id())
node_expander.expand_directory_recursively(state, root, prefetcher)
end
end
async.run(task, function()
log.debug("All nodes expanded - redrawing")
renderer.redraw(state)
end)
end
---Expand all subnodes
---@param node table? A single node to expand (defaults to node under the cursor)
---@param prefetcher table? an object with two methods `prefetch(state, node)` and `should_prefetch(node) => boolean`
M.expand_all_subnodes = function(state, node, prefetcher)
M.expand_all_nodes(state, node or state.tree:get_node(), prefetcher)
end
---@param callback function
M.close_node = function(state, callback)
local tree = state.tree
local node = assert(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
assert(target_node, "no node found to close")
local root = tree:get_nodes()[1]
local is_root = target_node:get_id() == root:get_id()
if target_node:has_children() and not is_root then
target_node:collapse()
renderer.redraw(state)
renderer.focus_node(state, target_node:get_id())
if state.explicitly_opened_nodes and state.explicitly_opened_nodes[target_node:get_id()] then
state.explicitly_opened_nodes[target_node:get_id()] = false
end
end
end
M.close_all_subnodes = function(state)
local tree = state.tree
local node = assert(tree:get_node())
local parent_node = assert(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())
if state.explicitly_opened_nodes and state.explicitly_opened_nodes[target_node:get_id()] then
state.explicitly_opened_nodes[target_node:get_id()] = false
end
end
---@param state neotree.State
M.close_all_nodes = function(state)
state.explicitly_opened_nodes = {}
renderer.collapse_all_nodes(state.tree)
renderer.redraw(state)
end
---@param state neotree.State
M.close_window = function(state)
renderer.close(state)
end
---@param state neotree.State
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
---@param state neotree.State
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.
---@param state neotree.State
M.copy_to_clipboard = function(state, callback)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
copy_node_to_clipboard(state, node)
if callback then
callback()
end
end
---@type neotree.TreeCommandVisual
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
---@param state neotree.State
---@param node NuiTree.Node
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 = assert(state.tree:get_node())
cut_node_to_clipboard(state, node)
if callback then
callback()
end
end
---@type neotree.TreeCommandVisual
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
--------------------------------------------------------------------------------
---@param state neotree.State
M.git_add_file = function(state)
local node = assert(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
---@param state neotree.State
M.git_add_all = function(state)
local cmd = { "git", "add", "-A" }
vim.fn.system(cmd)
events.fire_event(events.GIT_EVENT)
end
---@param state neotree.State
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 = assert(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_undo_last_commit = function(state)
inputs.confirm("Are you sure you want to undo the last commit? (keeps changes)", function(yes)
if yes then
local cmd = { "git", "reset", "--soft", "HEAD~1" }
local result = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 then
popups.alert("ERROR: git reset --soft HEAD~1", result)
return
end
events.fire_event(events.GIT_EVENT)
popups.alert(
"git reset --soft HEAD~1",
{ "Last commit undone successfully", "Changes kept in staging area" }
)
end
end)
end
M.git_revert_file = function(state)
local node = assert(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
--------------------------------------------------------------------------------
local get_sources = function()
local config = require("neo-tree").config
return config.source_selector.sources or config.sources
end
M.next_source = function(state)
local sources = get_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 = get_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
local function set_sort(state, label)
local sort = state.sort or { label = "Name", direction = -1 }
if sort.label == label then
sort.direction = sort.direction * -1
else
sort.label = label
sort.direction = -1
end
state.sort = sort
end
M.order_by_created = function(state)
set_sort(state, "Created")
state.sort_field_provider = function(node)
local stat = utils.get_stat(node)
return stat.birthtime and stat.birthtime.sec or 0
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_modified = function(state)
set_sort(state, "Last Modified")
state.sort_field_provider = function(node)
local stat = utils.get_stat(node)
return stat.mtime and stat.mtime.sec or 0
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_name = function(state)
set_sort(state, "Name")
local config = require("neo-tree").config
if config.sort_case_insensitive then
state.sort_field_provider = function(node)
return node.path:lower()
end
else
state.sort_field_provider = function(node)
return node.path
end
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_size = function(state)
set_sort(state, "Size")
state.sort_field_provider = function(node)
local stat = utils.get_stat(node)
return stat.size or 0
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_type = function(state)
set_sort(state, "Type")
state.sort_field_provider = function(node)
return node.ext or node.type
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_git_status = function(state)
set_sort(state, "Git Status")
state.sort_field_provider = function(node)
local git_status_lookup = state.git_status_lookup or {}
local git_status = git_status_lookup[node.path]
if git_status then
return git_status
end
if node.filtered_by and node.filtered_by.gitignored then
return "!!"
else
return ""
end
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_diagnostics = function(state)
set_sort(state, "Diagnostics")
state.sort_field_provider = function(node)
local diag = state.diagnostics_lookup or {}
local diagnostics = diag[node.path]
if not diagnostics then
return 0
end
if not diagnostics.severity_number then
return 0
end
-- lower severity number means higher severity
return 5 - diagnostics.severity_number
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.show_debug_info = function(state)
print(vim.inspect(state))
end
local default_filetime_format = "%Y-%m-%d %I:%M %p"
M.show_file_details = function(state)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
local stat = utils.get_stat(node)
local left = {}
local right = {}
table.insert(left, "Name")
table.insert(right, node.name)
table.insert(left, "Path")
table.insert(right, node:get_id())
table.insert(left, "Type")
table.insert(right, node.type)
if stat.size then
table.insert(left, "Size")
table.insert(right, utils.human_size(stat.size))
table.insert(left, "Created")
local created_format = state.config.created_format or default_filetime_format
table.insert(right, utils.date(created_format, stat.birthtime.sec))
table.insert(left, "Modified")
local modified_format = state.config.modified_format or default_filetime_format
table.insert(right, utils.date(modified_format, stat.mtime.sec))
end
local lines = {}
for i, v in ipairs(left) do
local line = string.format("%9s: %s", v, right[i])
table.insert(lines, line)
end
popups.alert("File Details", lines)
end
---Pastes all items from the clipboard to the current directory.
---@param callback fun(node: NuiTree.Node?, destination: string) 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 callback fun(parent_node: NuiTree.Node)
M.copy = function(state, callback)
local node = assert(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 callback fun(parent_node: NuiTree.Node)
M.move = function(state, callback)
local node = assert(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 node = assert(state.tree:get_node())
if node.type ~= "file" and node.type ~= "directory" then
log.warn("The `delete` command can only be used on files and directories")
return
end
if node:get_depth() == 1 then
log.error(
"Will not delete root node "
.. node.path
.. ", please back out of the current directory if you want to delete the root node."
)
return
end
fs_actions.delete_node(node.path, callback)
end
---@param callback function
---@type neotree.TreeCommandVisual
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:get_depth() == 1 then
log.error(
"Will not delete root node "
.. node_to_delete.path
.. ", please back out of the current directory if you want to delete the root node."
)
return
end
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.scroll_preview = function(state)
Preview.scroll(state)
end
M.focus_preview = function(state)
if Preview.is_active() then
Preview.focus()
else
vim.api.nvim_win_call(state.winid, function()
vim.api.nvim_feedkeys(state.fallback, "n", false)
end)
end
end
---Expands or collapses the current node.
M.toggle_node = function(state, toggle_directory)
local tree = state.tree
local node = assert(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 = assert(tree:get_node())
if node.type ~= "directory" then
return
end
M.toggle_node(state, toggle_directory)
end
---Open file or expandable node
---@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 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
local config = state.config or {}
if node.type == "file" and config.no_expand_file ~= nil then
log.warn("`no_expand_file` options is deprecated, move to `expand_nested_files` (OPPOSITE)")
config.expand_nested_files = not config.no_expand_file
end
local should_expand_file = config.expand_nested_files and not node:is_expanded()
if utils.is_expandable(node) and (node.type ~= "file" or should_expand_file) then
M.toggle_node(state, toggle_directory)
else
open()
end
end
---Open file or directory in the closest window
---@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 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 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 right below vertical split of the closest window
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_rightbelow_vs = function(state, toggle_directory)
open_with_cmd(state, "rightbelow vs", toggle_directory)
end
---Open file or directory in a left above vertical split of the closest window
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_leftabove_vs = function(state, toggle_directory)
open_with_cmd(state, "leftabove vs", toggle_directory)
end
---Open file or directory in a new tab
---@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 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 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 = assert(tree:get_node())
if node.type == "message" then
return
end
fs_actions.rename_node(node.path, callback)
end
M.rename_basename = function(state, callback)
local tree = state.tree
local node = assert(tree:get_node())
if node.type == "message" then
return
end
fs_actions.rename_node_basename(node.path, callback)
end
---Marks potential windows with letters and will open the give node in the picked window.
---@param state neotree.State
---@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)
---@diagnostic disable-next-line: param-type-mismatch
local result, err = pcall(vim.cmd, cmd .. " " .. vim.fn.fnameescape(path))
if result or err == "Vim(edit):E325: ATTENTION" then
-- fixes #321
vim.bo[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)
local title = state.config and state.config.title or nil
local prefix_key = state.config and state.config.prefix_key or nil
help.show(state, title, prefix_key)
end
return M

View file

@ -0,0 +1,720 @@
-- 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 nt = require("neo-tree")
---@alias neotree.Component.Common._Key
---|"bufnr"
---|"clipboard"
---|"container"
---|"current_filter"
---|"diagnostics"
---|"git_status"
---|"filtered_by"
---|"icon"
---|"modified"
---|"name"
---|"indent"
---|"file_size"
---|"last_modified"
---|"created"
---|"symlink_target"
---|"type"
---@class neotree.Component.Common Use the neotree.Component.Common.* types to get more specific types.
---@field [1] neotree.Component.Common._Key
---@type table<neotree.Component.Common._Key, neotree.FileRenderer>
local M = {}
local make_two_char = function(symbol)
if vim.fn.strchars(symbol) == 1 then
return symbol .. " "
else
return symbol
end
end
---@class (exact) neotree.Component.Common.Bufnr : neotree.Component
---@field [1] "bufnr"?
-- Config fields below:
-- only works in the buffers component, but it's here so we don't have to defined
-- multple renderers.
---@param config neotree.Component.Common.Bufnr
M.bufnr = function(config, node, _)
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
---@class (exact) neotree.Component.Common.Clipboard : neotree.Component
---@field [1] "clipboard"?
---@param config neotree.Component.Common.Clipboard
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
---@class (exact) neotree.Component.Common.Container : neotree.Component
---@field [1] "container"?
---@field left_padding integer?
---@field right_padding integer?
---@field enable_character_fade boolean?
---@field content (neotree.Component|{zindex: number, align: "left"|"right"|nil})[]?
M.container = container.render
---@class (exact) neotree.Component.Common.CurrentFilter : neotree.Component
---@field [1] "current_filter"
---@param config neotree.Component.Common.CurrentFilter
M.current_filter = function(config, node, _)
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
---`sign_getdefined` based wrapper with compatibility
---@param severity string
---@return vim.fn.sign_getdefined.ret.item
local get_legacy_sign = function(severity)
local sign = vim.fn.sign_getdefined("DiagnosticSign" .. severity)
if vim.tbl_isempty(sign) then
-- backwards compatibility...
local old_severity = severity
if severity == "Warning" then
old_severity = "Warn"
elseif severity == "Information" then
old_severity = "Info"
end
sign = vim.fn.sign_getdefined("LspDiagnosticsSign" .. old_severity)
end
return sign and sign[1]
end
local nvim_0_10 = vim.fn.has("nvim-0.10") > 0
---Returns the sign corresponding to the given severity
---@param severity string
---@return vim.fn.sign_getdefined.ret.item
local function get_diagnostic_sign(severity)
local sign
if nvim_0_10 then
local signs = vim.diagnostic.config().signs
if type(signs) == "function" then
--TODO: Find a better way to get a namespace
local namespaces = vim.diagnostic.get_namespaces()
if not vim.tbl_isempty(namespaces) then
local ns_id = next(namespaces)
---@cast ns_id -nil
signs = signs(ns_id, 0)
end
end
if type(signs) == "table" then
local identifier = severity:sub(1, 1)
if identifier == "H" then
identifier = "N"
end
sign = {
text = (signs.text or {})[vim.diagnostic.severity[identifier]],
texthl = "DiagnosticSign" .. severity,
}
elseif signs == true then
sign = get_legacy_sign(severity)
end
else -- before 0.10
sign = get_legacy_sign(severity)
end
if type(sign) ~= "table" then
sign = {}
end
return sign
end
---@class (exact) neotree.Component.Common.Diagnostics : neotree.Component
---@field [1] "diagnostics"?
---@field errors_only boolean?
---@field hide_when_expanded boolean?
---@field symbols table<string, string>?
---@field highlights table<string, string>?
---@param config neotree.Component.Common.Diagnostics
M.diagnostics = function(config, node, state)
local diag = state.diagnostics_lookup or {}
local diag_state = utils.index_by_path(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
---@type string
local severity = diag_state.severity_string
local sign = get_diagnostic_sign(severity)
-- check for overrides in the component config
local severity_lower = severity:lower()
if config.symbols and config.symbols[severity_lower] then
sign.texthl = sign.texthl or ("Diagnostic" .. severity)
sign.text = config.symbols[severity_lower]
end
if config.highlights and config.highlights[severity_lower] then
sign.text = sign.text or severity:sub(1, 1)
sign.texthl = config.highlights[severity_lower]
end
if sign.text and sign.texthl then
return {
text = make_two_char(sign.text),
highlight = sign.texthl,
}
else
return {
text = severity:sub(1, 1),
highlight = "Diagnostic" .. severity,
}
end
end
---@class (exact) neotree.Component.Common.GitStatus : neotree.Component
---@field [1] "git_status"?
---@field hide_when_expanded boolean?
---@field symbols table<string, string>?
---@param config neotree.Component.Common.GitStatus
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
---@type string?
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
---@class neotree.Component.Common.FilteredBy
---@field [1] "filtered_by"?
M.filtered_by = function(_, node, state)
local fby = node.filtered_by
if not state.filtered_items or type(fby) ~= "table" then
return {}
end
repeat
if fby.name then
return {
text = "(hide by name)",
highlight = highlights.HIDDEN_BY_NAME,
}
elseif fby.pattern then
return {
text = "(hide by pattern)",
highlight = highlights.HIDDEN_BY_NAME,
}
elseif fby.gitignored then
return {
text = "(gitignored)",
highlight = highlights.GIT_IGNORED,
}
elseif fby.dotfiles then
return {
text = "(dotfile)",
highlight = highlights.DOTFILE,
}
elseif fby.hidden then
return {
text = "(hidden)",
highlight = highlights.WINDOWS_HIDDEN,
}
end
fby = fby.parent
until not state.filtered_items.children_inherit_highlights or not fby
return {}
end
---@class (exact) neotree.Component.Common.Icon : neotree.Component
---@field [1] "icon"?
---@field default string The default icon for a node.
---@field folder_empty string The string to display to represent an empty folder.
---@field folder_empty_open string The icon to display to represent an empty but open folder.
---@field folder_open string The icon to display for an open folder.
---@field folder_closed string The icon to display for a closed folder.
---@field provider neotree.IconProvider?
---@param config neotree.Component.Common.Icon
M.icon = function(config, node, state)
-- calculate default icon
---@type neotree.Render.Node
local icon =
{ text = config.default or " ", highlight = config.highlight or highlights.FILE_ICON }
if node.type == "directory" then
icon.highlight = highlights.DIRECTORY_ICON
if node.loaded and not node:has_children() then
icon.text = not node.empty_expanded and config.folder_empty or config.folder_empty_open
elseif node:is_expanded() then
icon.text = config.folder_open or "-"
else
icon.text = config.folder_closed or "+"
end
end
-- use icon provider if available
if config.provider then
icon = config.provider(icon, node, state) or icon
end
local filtered_by = M.filtered_by(config, node, state)
icon.text = icon.text .. " " -- add padding
icon.highlight = filtered_by.highlight or icon.highlight -- prioritize filtered highlighting
return icon
end
---@class (exact) neotree.Component.Common.Modified : neotree.Component
---@field [1] "modified"?
---@field symbol string?
---@param config neotree.Component.Common.Modified
M.modified = function(config, node, state)
local opened_buffers = state.opened_buffers or {}
local buf_info = utils.index_by_path(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
---@class (exact) neotree.Component.Common.Name : neotree.Component
---@field [1] "name"?
---@field trailing_slash boolean?
---@field use_git_status_colors boolean?
---@field highlight_opened_files boolean|"all"?
---@field right_padding integer?
---@param config neotree.Component.Common.Name
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
if state.current_position == "current" and state.sort and state.sort.label == "Name" then
local icon = state.sort.direction == 1 and "" or ""
text = text .. " " .. icon
end
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
---@class (exact) neotree.Component.Common.Indent : neotree.Component
---@field [1] "indent"?
---@field expander_collapsed string?
---@field expander_expanded string?
---@field expander_highlight string?
---@field indent_marker string?
---@field indent_size integer?
---@field last_indent_marker string?
---@field padding integer?
---@field with_expanders boolean?
---@field with_markers boolean?
---@param config neotree.Component.Common.Indent
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
---@cast skip_marker -nil
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,
no_next_padding = true,
})
end
return indent
end
local truncate_string = function(str, max_length)
if #str <= max_length then
return str
end
return str:sub(1, max_length - 1) .. ""
end
local get_header = function(state, label, size)
if state.sort and state.sort.label == label then
local icon = state.sort.direction == 1 and "" or ""
size = size - 2
---diagnostic here is wrong, printf has arbitrary args.
---@diagnostic disable-next-line: redundant-parameter
return vim.fn.printf("%" .. size .. "s %s ", truncate_string(label, size), icon)
end
return vim.fn.printf("%" .. size .. "s ", truncate_string(label, size))
end
---@class (exact) neotree.Component.Common.FileSize : neotree.Component
---@field [1] "file_size"?
---@field width integer?
---@param config neotree.Component.Common.FileSize
M.file_size = function(config, node, state)
-- Root node gets column labels
if node:get_depth() == 1 then
return {
text = get_header(state, "Size", config.width),
highlight = highlights.FILE_STATS_HEADER,
}
end
local text = "-"
if node.type == "file" then
local stat = utils.get_stat(node)
local size = stat and stat.size or nil
if size then
local success, human = pcall(utils.human_size, size)
if success then
text = human or text
end
end
end
return {
text = vim.fn.printf("%" .. config.width .. "s ", truncate_string(text, config.width)),
highlight = config.highlight or highlights.FILE_STATS,
}
end
---@class (exact) neotree.Component.Common._Time : neotree.Component
---@field format neotree.DateFormat
---@field width integer?
---@param config neotree.Component.Common._Time
local file_time = function(config, node, state, stat_field)
-- Root node gets column labels
if node:get_depth() == 1 then
local label = stat_field
if stat_field == "mtime" then
label = "Last Modified"
elseif stat_field == "birthtime" then
label = "Created"
end
return {
text = get_header(state, label, config.width),
highlight = highlights.FILE_STATS_HEADER,
}
end
local stat = utils.get_stat(node)
local value = stat and stat[stat_field]
local seconds = value and value.sec or nil
local display = seconds and utils.date(config.format, seconds) or "-"
return {
text = vim.fn.printf("%" .. config.width .. "s ", truncate_string(display, config.width)),
highlight = config.highlight or highlights.FILE_STATS,
}
end
---@class (exact) neotree.Component.Common.LastModified : neotree.Component.Common._Time
---@field [1] "last_modified"?
---@param config neotree.Component.Common.LastModified
M.last_modified = function(config, node, state)
return file_time(config, node, state, "mtime")
end
---@class (exact) neotree.Component.Common.Created : neotree.Component.Common._Time
---@field [1] "created"?
---@param config neotree.Component.Common.Created
M.created = function(config, node, state)
return file_time(config, node, state, "birthtime")
end
---@class (exact) neotree.Component.Common.SymlinkTarget : neotree.Component
---@field [1] "symlink_target"?
---@field text_format string?
---@param config neotree.Component.Common.SymlinkTarget
M.symlink_target = function(config, node, _)
if node.is_link then
return {
text = string.format(config.text_format or "-> %s", node.link_to),
highlight = config.highlight or highlights.SYMBOLIC_LINK_TARGET,
}
else
return {}
end
end
---@class (exact) neotree.Component.Common.Type : neotree.Component
---@field [1] "type"?
---@field width integer?
---@param config neotree.Component.Common.Type
M.type = function(config, node, state)
local text = node.ext or node.type
-- Root node gets column labels
if node:get_depth() == 1 then
return {
text = get_header(state, "Type", config.width),
highlight = highlights.FILE_STATS_HEADER,
}
end
return {
text = vim.fn.printf("%" .. config.width .. "s ", truncate_string(text, config.width)),
highlight = highlights.FILE_STATS,
}
end
return M

View file

@ -0,0 +1,339 @@
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 strwidth = vim.api.nvim_strwidth
local calc_rendered_width = function(rendered_item)
local width = 0
for _, item in ipairs(rendered_item) do
if item.text then
width = width + strwidth(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 window_width = vim.api.nvim_win_get_width(state.winid)
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 > 0 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
repeat
if item.enabled == false then
break
end
local required_width = item.required_width or 0
if required_width > window_width then
break
end
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
until true
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
local truncate = utils.truncate_by_cell
---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_width number The maximum number of characters to return.
local truncate_layer_keep_left = function(layer, skip_count, max_width)
local result = {}
local taken = 0
local skipped = 0
for _, item in ipairs(layer) do
local remaining_to_skip = skip_count - skipped
local text_width = strwidth(item.text)
if remaining_to_skip > 0 then
if text_width <= remaining_to_skip then
skipped = skipped + text_width
item.text = ""
else
item.text, text_width = truncate(item.text, text_width - remaining_to_skip, "right")
if text_width > max_width - taken then
item.text, text_width = truncate(item.text, max_width - taken)
end
table.insert(result, item)
taken = taken + text_width
skipped = skipped + remaining_to_skip
end
elseif taken <= max_width then
item.text, text_width = truncate(item.text, max_width - taken)
table.insert(result, item)
taken = taken + text_width
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_width number The maximum number of characters to return.
local truncate_layer_keep_right = function(layer, skip_count, max_width)
local result = {}
local taken = 0
local skipped = 0
for i = #layer, 1, -1 do
local item = layer[i]
local text_width = strwidth(item.text)
local remaining_to_skip = skip_count - skipped
if remaining_to_skip > 0 then
if text_width <= remaining_to_skip then
skipped = skipped + text_width
item.text = ""
else
item.text, text_width = truncate(item.text, text_width - remaining_to_skip)
if text_width > max_width - taken then
item.text, text_width = truncate(item.text, max_width - taken, "right")
end
table.insert(result, item)
taken = taken + text_width
skipped = skipped + remaining_to_skip
end
elseif taken <= max_width then
if text_width > max_width - taken then
item.text, text_width = truncate(item.text, max_width - taken, "right")
end
table.insert(result, item)
taken = taken + text_width
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
---@param config neotree.Component.Common.Container
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

View file

@ -0,0 +1,341 @@
local file_nesting = require("neo-tree.sources.common.file-nesting")
local utils = require("neo-tree.utils")
local log = require("neo-tree.log")
local uv = vim.uv or vim.loop
---@type neotree.Config.SortFunction
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
---@type neotree.Config.SortFunction
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
---Creates a sort function the will sort by the values returned by the field provider.
---@param field_provider neotree.Internal.SortFieldProvider a function that takes an item and returns a value to sort by.
---@param fallback_sort_function neotree.Config.SortFunction a sort function to use if the field provider returns the same value for both items.
---@return neotree.Config.SortFunction
local function make_sort_function(field_provider, fallback_sort_function, direction)
return function(a, b)
if a.type == b.type then
local a_field = field_provider(a)
local b_field = field_provider(b)
if a_field == b_field then
return fallback_sort_function(a, b)
else
if direction < 0 then
return a_field > b_field
else
return a_field < b_field
end
end
else
return a.type < b.type
end
end
end
---@param func neotree.Config.SortFunction?
---@return boolean
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
---@param tbl table
---@param sort_func neotree.Config.SortFunction?
---@param field_provider neotree.Internal.SortFieldProvider?
---@param direction? 1|0
local function deep_sort(tbl, sort_func, field_provider, direction)
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
---@cast sort_func -nil
if field_provider ~= nil then
sort_func = make_sort_function(field_provider, sort_func, direction)
end
end
table.sort(tbl, sort_func)
for _, item in pairs(tbl) do
if item.type == "directory" or item.children ~= nil then
deep_sort(item.children, sort_func)
end
end
end
---@param state neotree.State
local advanced_sort = function(tbl, state)
local sort_func = state.sort_function_override
local field_provider = state.sort_field_provider
local direction = state.sort and state.sort.direction or 1
deep_sort(tbl, sort_func, field_provider, direction)
end
local create_item, set_parents
---@alias neotree.Filetype
---|"file"
---|"link"
---|"directory"
---|"unknown"
---@class neotree.FileItemFilters
---@field never_show boolean?
---@field always_show boolean?
---@field name boolean?
---@field pattern boolean?
---@field dotfiles boolean?
---@field hidden boolean?
---@field gitignored boolean?
---@field parent neotree.FileItemFilters?
---@field show_gitignored boolean?
---@class (exact) neotree.FileItemExtra
---@field status string? Git status
---@class (exact) neotree.FileItem
---@field id string
---@field name string
---@field parent_path string?
---@field path string
---@field type neotree.Filetype|string
---@field is_reveal_target boolean
---@field contains_reveal_target boolean
---@field filtered_by neotree.FileItemFilters?
---@field extra neotree.FileItemExtra?
---@field status string? Git status
---@field is_nested boolean?
---@class (exact) neotree.FileItem.File : neotree.FileItem
---@field children table<string, neotree.FileItem?>?
---@field nesting_callback neotree.filenesting.Callback
---@field base string
---@field ext string
---@field exts string
---@field name_lcase string
---@class (exact) neotree.FileItem.Link : neotree.FileItem
---@field is_link boolean
---@field link_to string?
---@class (exact) neotree.FileItem.Directory : neotree.FileItem
---@field children table<string, neotree.FileItem?>
---@field loaded boolean
---@field search_pattern string?
---@param context neotree.FileItemContext
---@param path string
---@param _type neotree.Filetype?
---@param bufnr integer?
---@return neotree.FileItem
function create_item(context, path, _type, bufnr)
local parent_path, name = utils.split_path(utils.normalize_path(path))
name = name or ""
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] or context.item_exists[path] then
return context.folders[path] or context.nesting[path] or context.item_exists[path]
end
end
if _type == nil then
local stat = uv.fs_stat(path)
_type = stat and stat.type or "unknown"
end
local is_reveal_target = (path == context.path_to_reveal)
---@type neotree.FileItem
local item = {
id = id,
name = name,
parent_path = parent_path,
path = path,
type = _type,
is_reveal_target = is_reveal_target,
contains_reveal_target = is_reveal_target and utils.is_subpath(path, context.path_to_reveal),
}
if utils.is_windows then
if vim.fn.getftype(path) == "link" then
item.type = "link"
end
end
if item.type == "link" then
---@cast item neotree.FileItem.Link
item.is_link = true
item.link_to = uv.fs_realpath(path)
if item.link_to ~= nil then
item.type = uv.fs_stat(item.link_to).type
end
end
if item.type == "directory" then
---@cast item neotree.FileItem.Directory
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
---@cast item neotree.FileItem.File
item.base = item.name:match("^([-_,()%s%w%i]+)%.")
item.ext = item.name:match("%.([-_,()%s%w%i]+)$")
item.exts = item.name:match("^[-_,()%s%w%i]+%.(.*)")
item.name_lcase = item.name:lower()
local nesting_callback = file_nesting.get_nesting_callback(item)
if nesting_callback ~= nil then
item.children = {}
item.nesting_callback = nesting_callback
context.nesting[path] = item
end
end
local state = assert(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
else
if utils.is_filtered_by_pattern(f.always_show_by_pattern, path, name) then
item.filtered_by = item.filtered_by or {}
item.filtered_by.always_show = true
end
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
---@param context neotree.FileItemContext
---@param item neotree.FileItem
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 parent = context.folders[item.parent_path]
if not utils.truthy(item.parent_path) then
return
end
if 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
---@cast parent neotree.FileItem.Directory
context.folders[parent.id] = parent
set_parents(context, parent)
end
table.insert(parent.children, item)
context.item_exists[item.id] = true
if not item.filtered_by and parent.filtered_by then
item.filtered_by = {
parent = parent.filtered_by,
}
end
end
---@class (exact) neotree.FileItemContext
---@field state neotree.State?
---@field folders table<string, neotree.FileItem.Directory|neotree.FileItem.Link?>
---@field nesting neotree.FileItem[]
---@field item_exists table<string, boolean?>
---@field all_items table<string, neotree.FileItem?>
---@field path_to_reveal string?
---Create context to be used in other file-items functions.
---@param state neotree.State? The state of the file-items.
---@return neotree.FileItemContext
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,
advanced_sort = advanced_sort,
}

View file

@ -0,0 +1,324 @@
local utils = require("neo-tree.utils")
local globtopattern = require("neo-tree.sources.filesystem.lib.globtopattern")
local log = require("neo-tree.log")
-- File nesting a la JetBrains (#117).
local M = {}
---@alias neotree.filenesting.Callback fun(item: table, siblings: table[], rule: neotree.filenesting.Rule): neotree.filenesting.Matches
---@class neotree.filenesting.Matcher
---@field rules table<string, neotree.filenesting.Rule>|neotree.filenesting.Rule[]
---@field get_children neotree.filenesting.Callback
---@field get_nesting_callback fun(item: table): neotree.filenesting.Callback|nil A callback that returns all the files
local DEFAULT_PATTERN_PRIORITY = 100
---@class neotree.filenesting.Rule
---@field priority number? Default is 100. Higher is prioritized.
---@field _priority number The internal priority, lower is prioritized. Determined through priority and the key for the rule at setup.
---@class neotree.filenesting.Rule.Pattern : neotree.filenesting.Rule
---@field files string[]
---@field files_exact string[]?
---@field files_glob string[]?
---@field ignore_case boolean? Default is false
---@field pattern string
---@class neotree.filenesting.Matcher.Pattern : neotree.filenesting.Matcher
---@field rules neotree.filenesting.Rule.Pattern[]
local pattern_matcher = {
rules = {},
}
---@class neotree.filenesting.Rule.Extension : neotree.filenesting.Rule
---@field [integer] string
---@class neotree.filenesting.Matcher.Extension : neotree.filenesting.Matcher
---@field rules table<string, neotree.filenesting.Rule.Extension>
local extension_matcher = {
rules = {},
}
local matchers = {
pattern = pattern_matcher,
exts = extension_matcher,
}
---@class neotree.filenesting.Matches
---@field priority number
---@field parent table
---@field children table[]
extension_matcher.get_nesting_callback = function(item)
local rule = extension_matcher.rules[item.exts]
if utils.truthy(rule) then
return function(inner_item, siblings)
return {
parent = inner_item,
children = extension_matcher.get_children(inner_item, siblings, rule),
priority = rule._priority,
}
end
end
return nil
end
---@type neotree.filenesting.Callback
extension_matcher.get_children = function(item, siblings, rule)
local matching_files = {}
if siblings == nil then
return matching_files
end
for _, ext in pairs(rule) do
for _, sibling in pairs(siblings) do
if
sibling.id ~= item.id
and sibling.exts == ext
and item.base .. "." .. ext == sibling.name
then
table.insert(matching_files, sibling)
end
end
end
---@type neotree.filenesting.Matches
return matching_files
end
pattern_matcher.get_nesting_callback = function(item)
---@type neotree.filenesting.Rule.Pattern[]
local matching_rules = {}
for _, rule in ipairs(pattern_matcher.rules) do
if item.name:match(rule.pattern) then
table.insert(matching_rules, rule)
end
end
if #matching_rules > 0 then
return function(inner_item, siblings)
local match_set = {}
---@type neotree.filenesting.Matches[]
local all_item_matches = {}
for _, rule in ipairs(matching_rules) do
---@type neotree.filenesting.Matches
local item_matches = {
priority = rule._priority,
parent = inner_item,
children = {},
}
local matched_siblings = pattern_matcher.get_children(inner_item, siblings, rule)
for _, match in ipairs(matched_siblings) do
-- Use file path as key to prevent duplicates
if not match_set[match.id] then
match_set[match.id] = true
table.insert(item_matches.children, match)
end
end
table.insert(all_item_matches, item_matches)
end
return all_item_matches
end
end
return nil
end
local pattern_matcher_types = {
files_glob = {
get_pattern = function(pattern)
return globtopattern.globtopattern(pattern)
end,
match = function(filename, pattern)
return filename:match(pattern)
end,
},
files_exact = {
get_pattern = function(pattern)
return pattern
end,
match = function(filename, pattern)
return filename == pattern
end,
},
}
---@type neotree.filenesting.Callback
pattern_matcher.get_children = function(item, siblings, rule)
local matching_files = {}
if siblings == nil then
return matching_files
end
for type, type_functions in pairs(pattern_matcher_types) do
for _, pattern in pairs(rule[type] or {}) do
repeat
---@cast rule neotree.filenesting.Rule.Pattern
local item_name = rule.ignore_case and item.name:lower() or item.name
local success, replaced_pattern = pcall(string.gsub, item_name, rule.pattern, pattern)
if not success then
log.error("Error using file glob '" .. pattern .. "'; Error: " .. replaced_pattern)
break
end
for _, sibling in pairs(siblings) do
if sibling.id ~= item.id then
local sibling_name = rule.ignore_case and sibling.name:lower() or sibling.name
local glob_or_file = type_functions.get_pattern(replaced_pattern)
if type_functions.match(sibling_name, glob_or_file) then
table.insert(matching_files, sibling)
end
end
end
until true
end
end
return matching_files
end
---@type neotree.filenesting.Matcher[]
local enabled_matchers = {}
function M.is_enabled()
return not vim.tbl_isempty(enabled_matchers)
end
function M.nest_items(context)
if not M.is_enabled() or vim.tbl_isempty(context.nesting or {}) then
return
end
-- First collect all nesting relationships
---@type neotree.filenesting.Matches[]
local nesting_relationships = {}
for _, parent in pairs(context.nesting) do
local siblings = context.folders[parent.parent_path].children
vim.list_extend(nesting_relationships, parent.nesting_callback(parent, siblings))
end
table.sort(nesting_relationships, function(a, b)
if a.priority == b.priority then
return a.parent.id < b.parent.id
end
return a.priority < b.priority
end)
-- Then apply them in order
for _, relationship in ipairs(nesting_relationships) do
local folder = context.folders[relationship.parent.parent_path]
for _, sibling in ipairs(relationship.children) do
if not sibling.is_nested then
table.insert(relationship.parent.children, sibling)
sibling.is_nested = true
sibling.nesting_parent = relationship.parent
if folder ~= nil then
for index, file_to_check in ipairs(folder.children) do
if file_to_check.id == sibling.id then
table.remove(folder.children, index)
break
end
end
end
end
end
end
end
function M.get_nesting_callback(item)
local cbs = {}
for _, matcher in ipairs(enabled_matchers) do
local callback = matcher.get_nesting_callback(item)
if callback ~= nil then
table.insert(cbs, callback)
end
end
if #cbs <= 1 then
return cbs[1]
else
return function(...)
local res = {}
for _, cb in ipairs(cbs) do
vim.list_extend(res, cb(...))
end
return res
end
end
end
local function is_glob(str)
local test = str:gsub("\\[%*%?%[%]]", "")
local pos, _ = test:find("*")
return pos ~= nil
end
local function case_insensitive_pattern(pattern)
-- find an optional '%' (group 1) followed by any character (group 2)
local p = pattern:gsub("(%%?)(.)", function(percent, letter)
if percent ~= "" or not letter:match("%a") then
-- if the '%' matched, or `letter` is not a letter, return "as is"
return percent .. letter
else
-- else, return a case-insensitive character class of the matched letter
return string.format("[%s%s]", letter:lower(), letter:upper())
end
end)
return p
end
---Setup the module with the given config
---@param config table<string, neotree.filenesting.Rule>
function M.setup(config)
config = config or {}
enabled_matchers = {}
local real_priority = 0
for _, m in pairs(matchers) do
m.rules = {}
end
for key, rule in
utils.spairs(config, function(a, b)
-- Organize by priority (descending) or by key (ascending)
local a_prio = config[a].priority or DEFAULT_PATTERN_PRIORITY
local b_prio = config[b].priority or DEFAULT_PATTERN_PRIORITY
if a_prio == b_prio then
return a < b
end
return a_prio > b_prio
end)
do
rule.priority = rule.priority or DEFAULT_PATTERN_PRIORITY
rule._priority = real_priority
real_priority = real_priority + 1
if rule.pattern then
---@cast rule neotree.filenesting.Rule.Pattern
rule.ignore_case = rule.ignore_case or false
if rule.ignore_case then
rule.pattern = case_insensitive_pattern(rule.pattern)
end
rule.files_glob = {}
rule.files_exact = {}
for _, glob in pairs(rule.files) do
if rule.ignore_case then
glob = glob:lower()
end
local replaced = glob:gsub("%%%d+", "")
if is_glob(replaced) then
table.insert(rule.files_glob, glob)
else
table.insert(rule.files_exact, glob)
end
end
-- priority does matter for pattern.rules
table.insert(matchers.pattern.rules, rule)
else
---@cast rule neotree.filenesting.Rule.Extension
matchers.exts.rules[key] = rule
end
end
enabled_matchers = vim.tbl_filter(function(m)
return not vim.tbl_isempty(m.rules)
end, matchers)
end
return M

View file

@ -0,0 +1,249 @@
-- 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
---@type integer?
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

View file

@ -0,0 +1,355 @@
---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 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 compat = require("neo-tree.utils._compat")
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 = {}
---Reset the current filter to the empty string.
---@param state neotree.State
---@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, compat.noref())
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 neotree.State 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 <CR> 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
if state.config.title then
popup_msg = state.config.title
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)
---@alias neotree.FuzzyFinder.BuiltinCommandNames
---|"move_cursor_down"
---|"move_cursor_up"
---|"close"
---|"close_clear_filter"
---|"close_keep_filter"
---|neotree.FuzzyFinder.FalsyMappingNames
---@alias neotree.FuzzyFinder.CommandFunction fun(state: neotree.State, scroll_padding: integer):string?
---@class neotree.FuzzyFinder.BuiltinCommands
---@field [string] neotree.FuzzyFinder.CommandFunction?
local cmds
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,
close = function(_state)
vim.cmd("stopinsert")
input:unmount()
if utils.truthy(_state.search_pattern) then
reset_filter(_state, true)
end
restore_height()
end,
close_keep_filter = function(_state, _scroll_padding)
log.info("Persisting the search filter")
keep_filter_on_submit = true
cmds.close(_state, _scroll_padding)
end,
close_clear_filter = function(_state, _scroll_padding)
log.info("Clearing the search filter")
keep_filter_on_submit = false
cmds.close(_state, _scroll_padding)
end,
}
M.setup_hooks(input, cmds, state, scroll_padding)
M.setup_mappings(input, cmds, state, scroll_padding)
end
---@param input NuiInput
---@param cmds neotree.FuzzyFinder.BuiltinCommands
---@param state neotree.State
---@param scroll_padding integer
function M.setup_hooks(input, cmds, state, scroll_padding)
input:on(
{ event.BufLeave, event.BufDelete },
utils.wrap(cmds.close, state, scroll_padding),
{ once = true }
)
-- hacky bugfix for quitting from the filter window
input:on("QuitPre", function()
if vim.api.nvim_get_current_win() ~= input.winid then
return
end
---'confirm' can cause blocking user input on exit, so this hack disables it.
local old_confirm = vim.o.confirm
vim.o.confirm = false
vim.schedule(function()
vim.o.confirm = old_confirm
end)
end)
end
---@enum neotree.FuzzyFinder.FalsyMappingNames
M._falsy_mapping_names = { "noop", "none" }
---@alias neotree.FuzzyFinder.CommandOrName neotree.FuzzyFinder.CommandFunction|neotree.FuzzyFinder.BuiltinCommandNames
---@class neotree.FuzzyFinder.VerboseCommand
---@field [1] neotree.FuzzyFinder.Command
---@field [2] vim.keymap.set.Opts?
---@field raw boolean?
---@alias neotree.FuzzyFinder.Command neotree.FuzzyFinder.CommandOrName|neotree.FuzzyFinder.VerboseCommand|string
---@class neotree.FuzzyFinder.SimpleMappings : neotree.SimpleMappings
---@field [string] neotree.FuzzyFinder.Command?
---@class neotree.Config.FuzzyFinder.Mappings : neotree.FuzzyFinder.SimpleMappings, neotree.Mappings
---@field [integer] table<string, neotree.FuzzyFinder.SimpleMappings>
---@param input NuiInput
---@param cmds neotree.FuzzyFinder.BuiltinCommands
---@param state neotree.State
---@param scroll_padding integer
---@param mappings neotree.FuzzyFinder.SimpleMappings
---@param mode string
local function apply_simple_mappings(input, cmds, state, scroll_padding, mode, mappings)
---@param command neotree.FuzzyFinder.CommandFunction
---@return function
local function setup_command(command)
return utils.wrap(command, state, scroll_padding)
end
for lhs, rhs in pairs(mappings) do
if type(lhs) == "string" then
---@cast rhs neotree.FuzzyFinder.Command
local cmd, raw, opts
if type(rhs) == "table" then
---type doesn't narrow properly
---@cast rhs -neotree.FuzzyFinder.FalsyMappingNames
raw = rhs.raw
opts = vim.deepcopy(rhs)
opts[1] = nil
opts.raw = nil
cmd = rhs[1]
else
---type also doesn't narrow properly
---@cast rhs -neotree.FuzzyFinder.VerboseCommand
cmd = rhs
end
local cmdtype = type(cmd)
if cmdtype == "string" then
if raw then
input:map(mode, lhs, cmd, opts)
else
local command = cmds[cmd]
if command then
input:map(mode, lhs, setup_command(command), opts)
elseif not vim.tbl_contains(M._falsy_mapping_names, cmd) then
log.warn(
string.format("Invalid command in fuzzy_finder_mappings: ['%s'] = '%s'", lhs, cmd)
)
end
end
elseif cmdtype == "function" then
---@cast cmd -neotree.FuzzyFinder.VerboseCommand
input:map(mode, lhs, setup_command(cmd), opts)
end
end
end
end
---@param input NuiInput
---@param cmds neotree.FuzzyFinder.BuiltinCommands
---@param state neotree.State
---@param scroll_padding integer
function M.setup_mappings(input, cmds, state, scroll_padding)
local config = require("neo-tree").config
local ff_mappings = config.filesystem.window.fuzzy_finder_mappings or {}
apply_simple_mappings(input, cmds, state, scroll_padding, "i", ff_mappings)
for _, mappings_by_mode in ipairs(ff_mappings) do
for mode, mappings in pairs(mappings_by_mode) do
apply_simple_mappings(input, cmds, state, scroll_padding, mode, mappings)
end
end
end
return M

View file

@ -0,0 +1,172 @@
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 = {}
---@param text string
---@param highlight string?
local add_text = function(text, highlight)
local line = NuiLine()
line:append(text, highlight)
return line
end
---@param state neotree.State
---@param prefix_key string?
local get_sub_keys = function(state, prefix_key)
local keys = utils.get_keys(state.resolved_mappings, true)
if prefix_key then
local len = prefix_key:len()
local sub_keys = {}
for _, key in ipairs(keys) do
if #key > len and key:sub(1, len) == prefix_key then
table.insert(sub_keys, key)
end
end
return sub_keys
else
return keys
end
end
---@param key string
---@param prefix string?
local function key_minus_prefix(key, prefix)
if prefix then
return key:sub(prefix:len() + 1)
else
return key
end
end
---Shows a help screen for the mapped commands when will execute those commands
---when the corresponding key is pressed.
---@param state neotree.State state of the source.
---@param title string? if this is a sub-menu for a multi-key mapping, the title for the window.
---@param prefix_key string? if this is a sub-menu, the start of tehe multi-key mapping
M.show = function(state, title, prefix_key)
local tree_width = vim.api.nvim_win_get_width(state.winid)
local keys = get_sub_keys(state, prefix_key)
local lines = { add_text("") }
lines[1] = add_text(" Press the corresponding key to execute the command.", "Comment")
lines[2] = add_text(" Press <Esc> 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
---@type neotree.State.ResolvedMapping
local value = state.resolved_mappings[key]
or { text = "<error mapping for key " .. key .. ">", handler = function() end }
local nline = NuiLine()
nline:append(string.format(" %14s", key_minus_prefix(key, prefix_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)
local col
if state.current_position == "right" then
col = vim.o.columns - tree_width - width - 1
else
col = tree_width - 1
end
---@type nui_popup_options
local options = {
position = {
row = 2,
col = col,
},
size = {
width = width,
height = #keys + 5,
},
enter = true,
focusable = true,
zindex = 50,
relative = "editor",
win_options = {
foldenable = false, -- Prevent folds from hiding lines
},
}
---@return integer lines The number of screen lines that the popup should occupy at most
local popup_max_height = function()
-- statusline
local statusline_lines = 0
local laststatus = vim.o.laststatus
if laststatus ~= 0 then
local windows = vim.api.nvim_tabpage_list_wins(0)
if (laststatus == 1 and #windows > 1) or laststatus > 1 then
statusline_lines = 1
end
end
-- tabs
local tab_lines = 0
local showtabline = vim.o.showtabline
if showtabline ~= 0 then
local tabs = vim.api.nvim_list_tabpages()
if (showtabline == 1 and #tabs > 1) or showtabline == 2 then
tab_lines = 1
end
end
return vim.o.lines - vim.o.cmdheight - statusline_lines - tab_lines - 2
end
local max_height = popup_max_height()
if options.size.height > max_height then
options.size.height = max_height
end
title = title or "Neotree Help"
options = popups.popup_options(title, width, options)
local popup = Popup(options)
popup:mount()
local event = require("nui.utils.autocmd").event
popup:on({ event.VimResized }, function()
popup:update_layout({
size = {
height = math.min(options.size.height --[[@as integer]], popup_max_height()),
width = math.min(options.size.width --[[@as integer]], vim.o.columns - 2),
},
})
end)
popup:on({ event.BufLeave, event.BufDelete }, function()
popup:unmount()
end, { once = true })
popup:map("n", "<esc>", function()
popup:unmount()
end, { noremap = true })
for _, key in ipairs(keys) do
-- map everything except for <escape>
if string.match(key:lower(), "^<esc") == nil then
local value = state.resolved_mappings[key]
or { text = "<error mapping for key " .. key .. ">", handler = function() end }
popup:map("n", key_minus_prefix(key, prefix_key), function()
popup:unmount()
vim.api.nvim_set_current_win(state.winid)
value.handler()
end)
end
end
for i, line in ipairs(lines) do
line:render(popup.bufnr, -1, i)
end
end
return M

View file

@ -0,0 +1,49 @@
local events = require("neo-tree.events")
local log = require("neo-tree.log")
local manager = require("neo-tree.sources.manager")
local M = {}
local hijack_cursor_handler = function()
if vim.o.filetype ~= "neo-tree" then
return
end
local success, source = pcall(vim.api.nvim_buf_get_var, 0, "neo_tree_source")
if not success then
log.debug("Cursor hijack failure: " .. vim.inspect(source))
return
end
local winid = nil
local _, position = pcall(vim.api.nvim_buf_get_var, 0, "neo_tree_position")
if position == "current" then
winid = vim.api.nvim_get_current_win()
end
local state = manager.get_state(source, nil, winid)
if not state or not state.tree then
return
end
local node = state.tree:get_node()
if not node then
return
end
log.debug("Cursor moved in tree window, hijacking cursor position")
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1]
local current_line = vim.api.nvim_get_current_line()
local startIndex, _ = string.find(current_line, node.name, nil, true)
if startIndex then
vim.api.nvim_win_set_cursor(0, { row, startIndex - 1 })
end
end
--Enables cursor hijack behavior for all sources
M.setup = function()
events.subscribe({
event = events.VIM_CURSOR_MOVED,
handler = hijack_cursor_handler,
id = "neo-tree-hijack-cursor",
})
end
return M

View file

@ -0,0 +1,85 @@
local log = require("neo-tree.log")
local utils = require("neo-tree.utils")
local M = {}
--- Recursively expand all loaded nodes under the given node
--- returns table with all discovered nodes that need to be loaded
---@param node table a node to expand
---@param state neotree.State current state of the source
---@return table discovered nodes that need to be loaded
local function expand_loaded(node, state, prefetcher)
local function rec(current_node, to_load)
if prefetcher.should_prefetch(current_node) then
log.trace("Node " .. current_node:get_id() .. "not loaded, saving for later")
table.insert(to_load, current_node)
else
if not current_node:is_expanded() then
current_node:expand()
state.explicitly_opened_nodes[current_node:get_id()] = true
end
local children = state.tree:get_nodes(current_node:get_id())
log.debug("Expanding childrens of " .. current_node:get_id())
for _, child in ipairs(children) do
if utils.is_expandable(child) then
rec(child, to_load)
else
log.trace("Child: " .. (child.name or "") .. " is not expandable, skipping")
end
end
end
end
local to_load = {}
rec(node, to_load)
return to_load
end
--- Recursively expands all nodes under the given node collecting all unloaded nodes
--- Then run prefetcher on all unloaded nodes. Finally, expand loded nodes.
--- async method
---@param node table a node to expand
---@param state neotree.State current state of the source
local function expand_and_load(node, state, prefetcher)
local to_load = expand_loaded(node, state, prefetcher)
for _, _node in ipairs(to_load) do
prefetcher.prefetch(state, _node)
-- no need to handle results as prefetch is recursive
expand_loaded(_node, state, prefetcher)
end
end
--- Expands given node recursively loading all descendant nodes if needed
--- Nodes will be loaded using given prefetcher
--- async method
---@param state neotree.State current state of the source
---@param node table a node to expand
---@param prefetcher table? an object with two methods `prefetch(state, node)` and `should_prefetch(node) => boolean`
M.expand_directory_recursively = function(state, node, prefetcher)
log.debug("Expanding directory " .. node:get_id())
prefetcher = prefetcher or M.default_prefetcher
if not utils.is_expandable(node) then
return
end
state.explicitly_opened_nodes = state.explicitly_opened_nodes or {}
if prefetcher.should_prefetch(node) then
local id = node:get_id()
state.explicitly_opened_nodes[id] = true
prefetcher.prefetch(state, node)
expand_loaded(node, state, prefetcher)
else
expand_and_load(node, state, prefetcher)
end
end
M.default_prefetcher = {
prefetch = function(state, node)
log.debug("Default expander prefetch does nothing")
end,
should_prefetch = function(node)
return false
end,
}
return M

View file

@ -0,0 +1,572 @@
local utils = require("neo-tree.utils")
local highlights = require("neo-tree.ui.highlights")
local events = require("neo-tree.events")
local manager = require("neo-tree.sources.manager")
local log = require("neo-tree.log")
local renderer = require("neo-tree.ui.renderer")
local NuiPopup = require("nui.popup")
---@class neotree.Preview.Config
---@field use_float boolean?
---@field use_image_nvim boolean?
---@field use_snacks_image boolean?
---@class neotree.Preview.Event
---@field source string?
---@field event neotree.event.Handler
---@class neotree.Preview
---@field config neotree.Preview.Config?
---@field active boolean Whether the preview is active.
---@field winid integer The id of the window being used to preview.
---@field is_neo_tree_window boolean Whether the preview window belongs to neo-tree.
---@field bufnr number The buffer that is currently in the preview window.
---@field start_pos integer[]? An array-like table specifying the (0-indexed) starting position of the previewed text.
---@field end_pos integer[]? An array-like table specifying the (0-indexed) ending position of the preview text.
---@field truth table A table containing information to be restored when the preview ends.
---@field events neotree.Preview.Event[] A list of events the preview is subscribed to.
local Preview = {}
---@type neotree.Preview?
local instance = nil
local neo_tree_preview_namespace = vim.api.nvim_create_namespace("neo_tree_preview")
---@param state neotree.State
local function create_floating_preview_window(state)
local default_position = utils.resolve_config_option(state, "window.position", "left")
state.current_position = state.current_position or default_position
local title = state.config.title or "Neo-tree Preview"
local winwidth = vim.api.nvim_win_get_width(state.winid)
local winheight = vim.api.nvim_win_get_height(state.winid)
local height = vim.o.lines - 4
local width = 120
local row, col = 0, 0
if state.current_position == "left" then
col = winwidth + 1
width = math.min(vim.o.columns - col, 120)
elseif state.current_position == "top" or state.current_position == "bottom" then
height = height - winheight
width = winwidth - 2
if state.current_position == "top" then
row = vim.api.nvim_win_get_height(state.winid) + 1
end
elseif state.current_position == "right" then
width = math.min(vim.o.columns - winwidth - 4, 120)
col = vim.o.columns - winwidth - width - 3
elseif state.current_position == "float" then
local pos = vim.api.nvim_win_get_position(state.winid)
-- preview will be same height and top as tree
row = pos[1]
height = winheight
-- tree and preview window will be side by side and centered in the editor
width = math.min(vim.o.columns - winwidth - 4, 120)
local total_width = winwidth + width + 4
local margin = math.floor((vim.o.columns - total_width) / 2)
col = margin + winwidth + 2
-- move the tree window to make the combined layout centered
local popup = renderer.get_nui_popup(state.winid)
popup:update_layout({
relative = "editor",
position = {
row = row,
col = margin,
},
})
else
local cur_pos = state.current_position or "unknown"
log.error('Preview cannot be used when position = "' .. cur_pos .. '"')
return
end
if height < 5 or width < 5 then
log.error(
"Preview cannot be used without any space, please resize the neo-tree split to allow for at least 5 cells of free space."
)
return
end
local popups = require("neo-tree.ui.popups")
local options = popups.popup_options(title, width, {
ns_id = highlights.ns_id,
size = { height = height, width = width },
relative = "editor",
position = {
row = row,
col = col,
},
win_options = {
number = true,
winhighlight = "Normal:"
.. highlights.FLOAT_NORMAL
.. ",FloatBorder:"
.. highlights.FLOAT_BORDER,
},
})
options.zindex = 40
options.buf_options.filetype = "neo-tree-preview"
local win = NuiPopup(options)
win:mount()
return win
end
---Creates a new preview.
---@param state neotree.State The state of the source.
---@return neotree.Preview preview A new preview. A preview is a table consisting of the following keys:
--These keys should not be altered directly. Note that the keys `start_pos`, `end_pos` and `truth`
--may be inaccurate if `active` is false.
function Preview:new(state)
local preview = {}
preview.active = false
preview.config = vim.deepcopy(state.config)
setmetatable(preview, { __index = self })
preview:findWindow(state)
return preview
end
---Preview a buffer in the preview window and optionally reveal and highlight the previewed text.
---@param bufnr integer? The number of the buffer to be previewed.
---@param start_pos integer[]? The (0-indexed) starting position of the previewed text. May be absent.
---@param end_pos integer[]? The (0-indexed) ending position of the previewed text. May be absent
function Preview:preview(bufnr, start_pos, end_pos)
if self.is_neo_tree_window then
log.warn("Could not find appropriate window for preview")
return
end
bufnr = bufnr or self.bufnr
if not self.active then
self:activate()
end
if not self.active then
return
end
self:setBuffer(bufnr)
self.start_pos = start_pos
self.end_pos = end_pos
self:reveal()
self:highlight_preview_range()
end
---Reverts the preview and inactivates it, restoring the preview window to its previous state.
function Preview:revert()
self.active = false
self:unsubscribe()
if not renderer.is_window_valid(self.winid) then
self.winid = nil
return
end
if self.config.use_float then
vim.api.nvim_win_close(self.winid, true)
self.winid = nil
return
else
local foldenable = utils.get_value(self.truth, "options.foldenable", nil, false)
if foldenable ~= nil then
vim.wo[self.winid].foldenable = self.truth.options.foldenable
end
vim.api.nvim_win_set_var(self.winid, "neo_tree_preview", 0)
end
local bufnr = self.truth.bufnr
if type(bufnr) ~= "number" then
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
self:setBuffer(bufnr)
if vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_call(self.winid, function()
vim.fn.winrestview(self.truth.view)
end)
end
vim.bo[self.bufnr].bufhidden = self.truth.options.bufhidden
end
---Subscribe to event and add it to the preview event list.
---@param source string? Name of the source to add the event to. Will use `events.subscribe` if nil.
---@param event neotree.event.Handler Event to subscribe to.
function Preview:subscribe(source, event)
if source == nil then
events.subscribe(event)
else
manager.subscribe(source, event)
end
self.events = self.events or {}
table.insert(self.events, { source = source, event = event })
end
---Unsubscribe to all events in the preview event list.
function Preview:unsubscribe()
if self.events == nil then
return
end
for _, event in ipairs(self.events) do
if event.source == nil then
events.unsubscribe(event.event)
else
manager.unsubscribe(event.source, event.event)
end
end
self.events = {}
end
---Finds the appropriate window and updates the preview accordingly.
---@param state neotree.State The state of the source.
function Preview:findWindow(state)
local winid, is_neo_tree_window
if self.config.use_float then
if
type(self.winid) == "number"
and vim.api.nvim_win_is_valid(self.winid)
and utils.is_floating(self.winid)
then
return
end
local win = create_floating_preview_window(state)
if not win then
self.active = false
return
end
winid = win.winid
is_neo_tree_window = false
else
winid, is_neo_tree_window = utils.get_appropriate_window(state)
self.bufnr = vim.api.nvim_win_get_buf(winid)
end
if winid == self.winid then
return
end
self.winid, self.is_neo_tree_window = winid, is_neo_tree_window
if self.active then
self:revert()
self:preview()
end
end
---Activates the preview, but does not populate the preview window,
function Preview:activate()
if self.active then
return
end
if not renderer.is_window_valid(self.winid) then
return
end
if self.config.use_float then
self.bufnr = vim.api.nvim_create_buf(false, true)
self.truth = {}
else
self.truth = {
bufnr = self.bufnr,
view = vim.api.nvim_win_call(self.winid, vim.fn.winsaveview),
options = {
bufhidden = vim.bo[self.bufnr].bufhidden,
foldenable = vim.wo[self.winid].foldenable,
},
}
vim.bo[self.bufnr].bufhidden = "hide"
vim.wo[self.winid].foldenable = false
end
self.active = true
vim.api.nvim_win_set_var(self.winid, "neo_tree_preview", 1)
end
---@param winid number
---@param bufnr number
---@return boolean hijacked Whether the buffer was successfully hijacked.
local function try_load_image_nvim_buf(winid, bufnr)
-- notify only image.nvim to let it try and hijack
local image_augroup = vim.api.nvim_create_augroup("image.nvim", { clear = false })
if #vim.api.nvim_get_autocmds({ group = image_augroup }) == 0 then
local image_available, image = pcall(require, "image")
if not image_available then
local image_nvim_url = "https://github.com/3rd/image.nvim"
log.debug(
"use_image_nvim was set but image.nvim was not found. Install from: " .. image_nvim_url
)
return false
end
log.warn("image.nvim was not setup. Calling require('image').setup().")
image.setup()
end
vim.opt.eventignore:remove("BufWinEnter")
local ok = pcall(vim.api.nvim_win_call, winid, function()
vim.api.nvim_exec_autocmds("BufWinEnter", { group = image_augroup, buffer = bufnr })
end)
vim.opt.eventignore:append("BufWinEnter")
if not ok then
log.debug("image.nvim doesn't have any file patterns to hijack.")
return false
end
if vim.bo[bufnr].filetype ~= "image_nvim" then
return false
end
return true
end
---@param bufnr number The buffer number of the buffer to set.
---@return number bytecount The number of bytes in the buffer
local get_bufsize = function(bufnr)
return vim.api.nvim_buf_call(bufnr, function()
return vim.fn.line2byte(vim.fn.line("$") + 1)
end)
end
events.subscribe({
event = events.NEO_TREE_PREVIEW_BEFORE_RENDER,
---@param args neotree.event.args.PREVIEW_BEFORE_RENDER
handler = function(args)
local preview = args.preview
local bufnr = args.bufnr
if not preview.config.use_snacks_image then
return
end
-- check if snacks.image is available
local snacks_image_ok, image = pcall(require, "snacks.image")
if not snacks_image_ok then
local snacks_nvim_url = "https://github.com/folke/snacks.nvim"
log.debug(
"use_snacks_image was set but snacks.nvim was not found. Install from: " .. snacks_nvim_url
)
return
end
local bufname = vim.api.nvim_buf_get_name(bufnr)
-- try attaching it
if image.supports(bufname) then
image.placement.new(preview.bufnr, bufname)
vim.bo[preview.bufnr].modifiable = true
return { handled = true } -- let snacks.image handle the rest
end
end,
})
events.subscribe({
event = events.NEO_TREE_PREVIEW_BEFORE_RENDER,
---@param args neotree.event.args.PREVIEW_BEFORE_RENDER
handler = function(args)
local preview = args.preview
local bufnr = args.bufnr
if preview.config.use_image_nvim and try_load_image_nvim_buf(preview.winid, bufnr) then
-- calling the try method twice should be okay here, image.nvim should cache the image and displaying the image takes
-- really long anyways
vim.api.nvim_win_set_buf(preview.winid, bufnr)
return { handled = try_load_image_nvim_buf(preview.winid, bufnr) }
end
end,
})
---Set the buffer in the preview window without executing BufEnter or BufWinEnter autocommands.
---@param bufnr number The buffer number of the buffer to set.
function Preview:setBuffer(bufnr)
self:clearHighlight()
if bufnr == self.bufnr then
return
end
local eventignore = vim.opt.eventignore
vim.opt.eventignore:append("BufEnter,BufWinEnter")
repeat
---@class neotree.event.args.PREVIEW_BEFORE_RENDER
local args = {
preview = self,
bufnr = bufnr,
}
events.fire_event(events.NEO_TREE_PREVIEW_BEFORE_RENDER, args)
if self.config.use_float then
-- Workaround until https://github.com/neovim/neovim/issues/24973 is resolved or maybe 'previewpopup' comes in?
vim.fn.bufload(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, lines)
vim.api.nvim_win_set_buf(self.winid, self.bufnr)
-- I'm not sure why float windows won't show numbers without this
vim.wo[self.winid].number = true
-- code below is from mini.pick
-- only starts treesitter parser if the filetype is matching
local ft = vim.bo[bufnr].filetype
local bufsize = get_bufsize(bufnr)
if bufsize > 1024 * 1024 or bufsize > 1000 * #lines then
break -- goto end
end
local has_lang, lang = pcall(vim.treesitter.language.get_lang, ft)
lang = has_lang and lang or ft
local has_parser, parser =
pcall(vim.treesitter.get_parser, self.bufnr, lang, { error = false })
has_parser = has_parser and parser ~= nil
if has_parser then
has_parser = pcall(vim.treesitter.start, self.bufnr, lang)
end
if not has_parser then
vim.bo[self.bufnr].syntax = ft
end
else
vim.api.nvim_win_set_buf(self.winid, bufnr)
self.bufnr = bufnr
end
until true
vim.opt.eventignore = eventignore
end
---Move the cursor to the previewed position and center the screen.
function Preview:reveal()
local pos = self.start_pos or self.end_pos
if not self.active or not self.winid or not pos then
return
end
vim.api.nvim_win_set_cursor(self.winid, { (pos[1] or 0) + 1, pos[2] or 0 })
vim.api.nvim_win_call(self.winid, function()
vim.cmd("normal! zz")
end)
end
---Highlight the previewed range
function Preview:highlight_preview_range()
if not self.active or not self.bufnr then
return
end
local start_pos, end_pos = self.start_pos, self.end_pos
if not start_pos and not end_pos then
return
end
if not start_pos then
---@cast end_pos table
start_pos = end_pos
elseif not end_pos then
---@cast start_pos table
end_pos = start_pos
end
local start_line, end_line = start_pos[1], end_pos[1]
local start_col, end_col = start_pos[2], end_pos[2]
vim.api.nvim_buf_set_extmark(self.bufnr, neo_tree_preview_namespace, start_line, start_col, {
hl_group = highlights.PREVIEW,
end_row = end_line,
end_col = end_col,
-- priority = priority,
strict = false,
})
end
---Clear the preview highlight in the buffer currently in the preview window.
function Preview:clearHighlight()
if type(self.bufnr) == "number" and vim.api.nvim_buf_is_valid(self.bufnr) then
vim.api.nvim_buf_clear_namespace(self.bufnr, neo_tree_preview_namespace, 0, -1)
end
end
local toggle_state = false
Preview.hide = function()
toggle_state = false
if instance then
instance:revert()
end
instance = nil
end
Preview.is_active = function()
return instance and instance.active
end
---@param state neotree.State
Preview.show = function(state)
local node = assert(state.tree:get_node())
if instance then
instance:findWindow(state)
else
instance = Preview:new(state)
end
local extra = node.extra or {}
local position = extra.position
local end_position = extra.end_position
local path = node.path or node:get_id()
local bufnr = extra.bufnr or vim.fn.bufadd(path)
if bufnr and bufnr > 0 and instance then
instance:preview(bufnr, position, end_position)
end
end
---@param state neotree.State
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()
local did_enter_preview = vim.api.nvim_get_current_win() == instance.winid
if not toggle_state or (did_enter_preview and instance.config.use_float) 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
---@cast instance table
vim.fn.win_gotoid(instance.winid)
end
end
local CTRL_E = utils.keycode("<c-e>")
local CTRL_Y = utils.keycode("<c-y>")
---@param state neotree.State
Preview.scroll = function(state)
local direction = state.config.direction
local input = direction < 0 and CTRL_E or CTRL_Y
local count = math.abs(direction)
if Preview:is_active() then
---@cast instance table
vim.api.nvim_win_call(instance.winid, function()
vim.cmd(("normal! %s%s"):format(count, input))
end)
else
vim.api.nvim_win_call(state.winid, function()
vim.api.nvim_feedkeys(state.fallback, "n", false)
end)
end
end
return Preview

View file

@ -0,0 +1,71 @@
--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")
---@class neotree.sources.DocumentSymbols.Commands : neotree.sources.Common.Commands
---@field [string] neotree.TreeCommand
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
---@param node NuiTree.Node
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 = assert(state.tree:get_node())
if node:get_depth() == 1 then
return
end
local old_name = node.name
---@param new_name string?
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

View file

@ -0,0 +1,66 @@
-- 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")
---@alias neotree.Component.DocumentSymbols._Key
---|"kind_icon"
---|"kind_name"
---|"name"
---@class neotree.Component.DocumentSymbols Use the neotree.Component.DocumentSymbols.* types to get more specific types.
---@field [1] neotree.Component.DocumentSymbols._Key|neotree.Component.Common._Key
---@type table<neotree.Component.DocumentSymbols._Key, neotree.Renderer>
local M = {}
---@class (exact) neotree.Component.DocumentSymbols.KindIcon : neotree.Component
---@field [1] "kind_icon"?
---@field provider neotree.IconProvider?
---@param config neotree.Component.DocumentSymbols.KindIcon
M.kind_icon = function(config, node, state)
local icon = {
text = node:get_depth() == 1 and "" or node.extra.kind.icon,
highlight = node.extra.kind.hl,
}
if config.provider then
icon = config.provider(icon, node, state) or icon
end
return icon
end
---@class (exact) neotree.Component.DocumentSymbols.KindName : neotree.Component
---@field [1] "kind_name"?
---@param config neotree.Component.DocumentSymbols.KindName
M.kind_name = function(config, node, state)
return {
text = node:get_depth() == 1 and "" or node.extra.kind.name,
highlight = node.extra and node.extra.kind.hl or highlights.FILE_NAME,
}
end
---@class (exact) neotree.Component.DocumentSymbols.Name : neotree.Component.Common.Name
---@param config neotree.Component.DocumentSymbols.Name
M.name = function(config, node, state)
return {
text = node.name,
highlight = node.extra and node.extra.kind.hl or highlights.FILE_NAME,
}
end
return vim.tbl_deep_extend("force", common, M)

View file

@ -0,0 +1,129 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
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")
---@class neotree.sources.DocumentSymbols : neotree.Source
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
---@class neotree.sources.documentsymbols.DebounceArgs
---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, path, path_to_reveal, callback, async)
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)
if type(callback) == "function" then
vim.schedule(callback)
end
end
---@class neotree.Config.LspKindDisplay
---@field icon string
---@field hl string
---@class neotree.Config.DocumentSymbols.Renderers : neotree.Config.Renderers
---@field root neotree.Component.DocumentSymbols[]?
---@field symbol neotree.Component.DocumentSymbols[]?
---@class (exact) neotree.Config.DocumentSymbols : neotree.Config.Source
---@field follow_cursor boolean?
---@field client_filters neotree.lsp.ClientFilter?
---@field custom_kinds table<integer, string>?
---@field kinds table<string, neotree.Config.LspKindDisplay>?
---@field renderers neotree.Config.DocumentSymbols.Renderers?
---Configures the plugin, should be called before the plugin is used.
---@param config neotree.Config.DocumentSymbols
---@param global_config neotree.Config.Base
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

View file

@ -0,0 +1,97 @@
---Utilities function to filter the LSP servers
local utils = require("neo-tree.utils")
---@class neotree.lsp.RespRaw
---@field err lsp.ResponseError?
---@field error lsp.ResponseError?
---@field result any
local M = {}
---@alias neotree.lsp.Filter fun(client_name: string): boolean
---Filter clients
---@param filter_type "first" | "all"
---@param filter_fn neotree.lsp.Filter?
---@param resp table<integer, neotree.lsp.RespRaw>
---@return table<string, any>
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 neotree.lsp.Filter
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 neotree.lsp.Filter
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 table<integer, neotree.lsp.RespRaw>
---@return table<string, any>
M.filter_resp = function(resp)
return {}
end
---@alias neotree.lsp.Filter.Type
---|"first" # Allow the first that matches
---|"all" # Allow all that match
---@alias neotree.lsp.ClientFilter neotree.lsp.Filter.Type | { type: neotree.lsp.Filter.Type, fn: neotree.lsp.Filter, allow_only: string[], ignore: string[] }
---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 neotree.lsp.ClientFilter
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

View file

@ -0,0 +1,67 @@
---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 = {}
---@class neotree.LspKindDisplay
---@field name string Display name
---@field icon string Icon to render
---@field hl string Highlight for the node
---Get how the kind with kind_id should be rendered
---@param kind_id integer the kind_id to be render
---@return neotree.LspKindDisplay res
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

View file

@ -0,0 +1,211 @@
---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 neotree.SymbolExtra
---@field bufnr integer the buffer containing the symbols,
---@field kind neotree.LspKindDisplay 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 neotree.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 neotree.SymbolNode[]
---@field extra neotree.SymbolExtra additional info
---Parse the lsp.Range
---@param range lsp.Range the lsp.Range 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 lsp.DocumentSymbol|lsp.SymbolInformation the LSP response node
---@param id string the id of the current node
---@return neotree.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
---@type neotree.SymbolNode
local symbol_node = {
id = id,
name = resp_node.name,
type = "symbol",
path = state.path,
children = children,
---@diagnostic disable-next-line: missing-fields
extra = {
bufnr = state.lsp_bufnr,
kind = kinds.get_kind(resp_node.kind),
search_path = search_path,
-- detail = resp_node.detail,
},
}
local preview_range = resp_node.range
if preview_range then
---@cast resp_node lsp.DocumentSymbol
symbol_node.extra.selection_range = parse_range(resp_node.selectionRange)
else
---@cast resp_node lsp.SymbolInformation
preview_range = resp_node.location.range
symbol_node.extra.selection_range = parse_range(preview_range)
end
preview_range = parse_range(preview_range)
symbol_node.extra.position = preview_range.start
symbol_node.extra.end_position = preview_range["end"]
return symbol_node
end
---Callback function for lsp request
---@param lsp_resp table<integer, neotree.lsp.RespRaw> the response of the lsp clients
---@param state neotree.State 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 = assert(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
---latter is deprecated in neovim v0.11
---@diagnostic disable-next-line: deprecated
local get_clients = vim.lsp.get_clients or vim.lsp.get_active_clients
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(get_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

View file

@ -0,0 +1,284 @@
--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 uv = vim.uv or vim.loop
---@class neotree.sources.Filesystem.Commands : neotree.sources.Common.Commands
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(fs.focus_destination_children, 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
---@type neotree.TreeCommandVisual
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
---@type neotree.TreeCommandVisual
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(fs.focus_destination_children, 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
---@type neotree.TreeCommandVisual
M.delete_visual = function(state, selected_nodes)
cc.delete_visual(state, selected_nodes, utils.wrap(refresh, state))
end
M.expand_all_nodes = function(state, node)
cc.expand_all_nodes(state, node, fs.prefetcher)
end
M.expand_all_subnodes = function(state, node)
cc.expand_all_subnodes(state, node, fs.prefetcher)
end
---Shows the filter input, which will filter the tree.
---@param state neotree.sources.filesystem.State
M.filter_as_you_type = function(state)
local config = state.config or {}
filter.show_filter(state, true, false, false, config.keep_filter_on_submit or false)
end
---Shows the filter input, which will filter the tree.
---@param state neotree.sources.filesystem.State
M.filter_on_submit = function(state)
filter.show_filter(state, false, false, false, true)
end
---Shows the filter input in fuzzy finder mode.
---@param state neotree.sources.filesystem.State
M.fuzzy_finder = function(state)
local config = state.config or {}
filter.show_filter(state, true, true, false, config.keep_filter_on_submit or false)
end
---Shows the filter input in fuzzy finder mode.
---@param state neotree.sources.filesystem.State
M.fuzzy_finder_directory = function(state)
local config = state.config or {}
filter.show_filter(state, true, "directory", false, config.keep_filter_on_submit or false)
end
---Shows the filter input in fuzzy sorter
---@param state neotree.sources.filesystem.State
M.fuzzy_sorter = function(state)
local config = state.config or {}
filter.show_filter(state, true, true, true, config.keep_filter_on_submit or false)
end
---Shows the filter input in fuzzy sorter with only directories
---@param state neotree.sources.filesystem.State
M.fuzzy_sorter_directory = function(state)
local config = state.config or {}
filter.show_filter(state, true, "directory", true, config.keep_filter_on_submit or false)
end
---Navigate up one level.
---@param state neotree.sources.filesystem.State
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(uv.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
---@param state neotree.sources.filesystem.State
M.next_git_modified = function(state)
focus_next_git_modified(state, false)
end
---@param state neotree.sources.filesystem.State
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_rightbelow_vs = function(state)
cc.open_rightbelow_vs(state, utils.wrap(fs.toggle_directory, state))
end
M.open_leftabove_vs = function(state)
cc.open_leftabove_vs(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
---@param state neotree.sources.filesystem.State
M.set_root = function(state)
if state.search_pattern then
fs.reset_search(state, false)
end
local node = state.tree:get_node()
while node and node.type ~= "directory" do
local parent_id = node:get_parent_id()
node = parent_id and state.tree:get_node(parent_id) or nil
end
if not node then
return
end
fs._navigate_internal(state, node:get_id(), nil, nil, false)
end
---Toggles whether hidden files are shown or not.
---@param state neotree.sources.filesystem.State
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.
---@param state neotree.sources.filesystem.State
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

View file

@ -0,0 +1,49 @@
-- 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")
---@alias neotree.Component.Filesystem._Key
---|"current_filter"
---@class neotree.Component.Filesystem
---@field [1] neotree.Component.Filesystem._Key|neotree.Component.Common._Key
---@type table<neotree.Component.Filesystem._Key, neotree.Renderer>
local M = {}
---@class (exact) neotree.Component.Filesystem.CurrentFilter : neotree.Component.Common.CurrentFilter
---@param config neotree.Component.Filesystem.CurrentFilter
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)

View file

@ -0,0 +1,536 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
local utils = require("neo-tree.utils")
local _compat = require("neo-tree.utils._compat")
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")
---@class neotree.sources.filesystem : neotree.Source
local M = {
name = "filesystem",
display_name = " 󰉓 Files ",
}
local wrap = function(func)
return utils.wrap(func, M.name)
end
---@return neotree.sources.filesystem.State
local get_state = function(tabid)
return manager.get_state(M.name, tabid) --[[@as neotree.sources.filesystem.State]]
end
local follow_internal = function(callback, force_show, async)
log.trace("follow called")
local state = get_state()
if vim.bo.filetype == "neo-tree" or vim.bo.filetype == "neo-tree-popup" then
return false
end
local path_to_reveal = utils.normalize_path(manager.get_path_to_reveal() or "")
if not utils.truthy(path_to_reveal) then
return false
end
---@cast path_to_reveal string
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()
state.explicitly_opened_nodes = state.explicitly_opened_nodes or {}
local expanded_nodes = renderer.get_expanded_nodes(state.tree)
local state_changed = false
for _, id in ipairs(expanded_nodes) do
if not state.explicitly_opened_nodes[id] then
if path_to_reveal:sub(1, #id) == id then
state.explicitly_opened_nodes[id] = state.follow_current_file.leave_dirs_open
else
local node = state.tree:get_node(id)
if node then
node:collapse()
state_changed = true
end
end
end
if state_changed then
renderer.redraw(state)
end
end
end
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
local fs_stat = (vim.uv or vim.loop).fs_stat
---@param state neotree.sources.filesystem.State
---@param path string?
---@param path_to_reveal string?
---@param callback function?
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
path = utils.normalize_path(path)
-- if path doesn't exist, navigate upwards until it does
local orig_path = path
local backed_out = false
while not fs_stat(path) do
log.debug(("navigate_internal: path %s didn't exist, going up a directory"):format(path))
backed_out = true
local parent, _ = utils.split_path(path)
if not parent then
break
end
path = parent
end
if backed_out then
log.warn(("Root path %s doesn't exist, backing out to %s"):format(orig_path, path))
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=", state.position.node_id)
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.enabled
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)
state._ready = false
log.trace("navigate", path, path_to_reveal, async)
utils.debounce("filesystem_navigate", function()
M._navigate_internal(state, path, path_to_reveal, callback, async)
end, 100, utils.debounce_strategy.CALL_FIRST_AND_LAST)
end
---@param state neotree.State
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.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, _compat.noref())
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
else
if refresh then
M.navigate(state)
end
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
M.focus_destination_children = function(state, move_from, destination)
return M.show_new_children(state, destination)
end
---@alias neotree.Config.Cwd "tab"|"window"|"global"
---@class neotree.Config.Filesystem.CwdTarget
---@field sidebar neotree.Config.Cwd?
---@field current neotree.Config.Cwd?
---@class neotree.Config.Filesystem.FilteredItems
---@field visible boolean?
---@field force_visible_in_empty_folder boolean?
---@field children_inherit_highlights boolean?
---@field show_hidden_count boolean?
---@field hide_dotfiles boolean?
---@field hide_gitignored boolean?
---@field hide_hidden boolean?
---@field hide_by_name string[]?
---@field hide_by_pattern string[]?
---@field always_show string[]?
---@field always_show_by_pattern string[]?
---@field never_show string[]?
---@field never_show_by_pattern string[]?
---@alias neotree.Config.Filesystem.FindArgsHandler fun(cmd:string, path:string, search_term:string, args:string[]):string[]
---@class neotree.Config.Filesystem.FollowCurrentFile
---@field enabled boolean?
---@field leave_dirs_open boolean?
---@alias neotree.Config.HijackNetrwBehavior
---|"open_default" # opening a directory opens neo-tree with the default window.position.
---|"open_current" # opening a directory opens neo-tree within the current window.
---|"disabled" # opening a directory opens neo-tree within the current window.
---@class neotree.Config.Filesystem.Renderers : neotree.Config.Renderers
---@class neotree.Config.Filesystem.Window : neotree.Config.Window
---@field fuzzy_finder_mappings neotree.Config.FuzzyFinder.Mappings?
---@alias neotree.Config.Filesystem.AsyncDirectoryScan
---|"auto"
---|"always"
---|"never"
---@alias neotree.Config.Filesystem.ScanMode
---|"shallow"
---|"deep"
---@class (exact) neotree.Config.Filesystem : neotree.Config.Source
---@field async_directory_scan neotree.Config.Filesystem.AsyncDirectoryScan?
---@field scan_mode neotree.Config.Filesystem.ScanMode?
---@field bind_to_cwd boolean?
---@field cwd_target neotree.Config.Filesystem.CwdTarget?
---@field check_gitignore_in_search boolean?
---@field filtered_items neotree.Config.Filesystem.FilteredItems?
---@field find_by_full_path_words boolean?
---@field find_command string?
---@field find_args table<string, string[]>|neotree.Config.Filesystem.FindArgsHandler|nil
---@field group_empty_dirs boolean?
---@field search_limit integer?
---@field follow_current_file neotree.Config.Filesystem.FollowCurrentFile?
---@field hijack_netrw_behavior neotree.Config.HijackNetrwBehavior?
---@field use_libuv_file_watcher boolean?
---@field renderers neotree.Config.Filesystem.Renderers?
---@field window neotree.Config.Filesystem.Window?
---@field enable_git_status boolean?
---Configures the plugin, should be called before the plugin is used.
---@param config neotree.Config.Filesystem Configuration table containing any keys that the user wants to change from the defaults. May be empty to accept default values.
---@param global_config neotree.Config.Base
M.setup = function(config, global_config)
config.filtered_items = config.filtered_items or {}
config.enable_git_status = config.enable_git_status or global_config.enable_git_status
for _, key in ipairs({ "hide_by_pattern", "always_show_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.enabled 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.
---@param state neotree.sources.filesystem.State
---@param node NuiTree.Node
---@param path_to_reveal string
---@param skip_redraw boolean?
---@param recursive boolean?
---@param callback function?
M.toggle_directory = function(state, node, path_to_reveal, skip_redraw, recursive, callback)
local tree = state.tree
if not node then
node = assert(tree:get_node())
end
if node.type ~= "directory" then
return
end
state.explicitly_opened_nodes = state.explicitly_opened_nodes or {}
if node.loaded == false then
local id = node:get_id()
state.explicitly_opened_nodes[id] = true
renderer.position.set(state, nil)
fs_scan.get_items(state, id, path_to_reveal, callback, false, recursive)
elseif node:has_children() then
local updated = false
if node:is_expanded() then
updated = node:collapse()
state.explicitly_opened_nodes[node:get_id()] = false
else
updated = node:expand()
state.explicitly_opened_nodes[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
M.prefetcher = {
---@param state neotree.sources.filesystem.State
---@param node NuiTree.Node
prefetch = function(state, node)
if node.type ~= "directory" then
return
end
log.debug("Running fs prefetch for: " .. node:get_id())
fs_scan.get_dir_items_async(state, node:get_id(), true)
end,
should_prefetch = function(node)
return not node.loaded
end,
}
return M

View file

@ -0,0 +1,247 @@
-- This file holds all code for the search function.
local Input = require("nui.input")
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 compat = require("neo-tree.utils._compat")
local common_filter = require("neo-tree.sources.common.filters")
local M = {}
---@param state neotree.sources.filesystem.State
---@param search_as_you_type boolean?
---@param fuzzy_finder_mode "directory"|boolean?
---@param use_fzy boolean?
---@param keep_filter_on_submit boolean?
M.show_filter = function(
state,
search_as_you_type,
fuzzy_finder_mode,
use_fzy,
keep_filter_on_submit
)
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.config.title then
popup_msg = state.config.title
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
---@type neotree.Config.SortFunction
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")
---@type table|nil
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 and not keep_filter_on_submit 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, compat.noref())
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
---@type function|nil
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)
---@class neotree.sources.filesystem.FuzzyFinder.BuiltinCommands : neotree.FuzzyFinder.BuiltinCommands
local cmds
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,
close = function(_state, _scroll_padding)
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)
and not keep_filter_on_submit
then
fs.reset_search(state, true)
end
end, 100)
restore_height()
end,
close_keep_filter = function(_state, _scroll_padding)
log.info("Persisting the search filter")
keep_filter_on_submit = true
cmds.close(_state, _scroll_padding)
end,
close_clear_filter = function(_state, _scroll_padding)
log.info("Clearing the search filter")
keep_filter_on_submit = false
cmds.close(_state, _scroll_padding)
end,
}
common_filter.setup_hooks(input, cmds, state, scroll_padding)
if not fuzzy_finder_mode then
return
end
common_filter.setup_mappings(input, cmds, state, scroll_padding)
end
return M

View file

@ -0,0 +1,393 @@
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 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 neotree.FileKind
---@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 kind neotree.FileKind | 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<string, string[]> 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: number): any Executed at the end.
M.filter_files_external = function(
cmd,
path,
glob,
regex,
full_path,
kind,
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 {}
kind = kind or {}
limit = limit or math.huge -- math.huge == no limit
local file_kind_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(kind) do
if v and file_kind_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_kinds = {}
for k, v in pairs(kind) do
if v and file_kind_map[k] ~= nil then
file_kinds[#file_kinds + 1] = file_kind_map[k]
end
end
if ignore.dotfiles then
append("-name", ".*", "-prune", "-o")
end
if #file_kinds > 0 then
append("-type", table.concat(file_kinds, ","))
end
if kind.empty then
append("-empty")
end
if kind.executable then
append("-executable")
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("-print")
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
---@diagnostic disable-next-line: missing-fields
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
---@param state neotree.State
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

View file

@ -0,0 +1,719 @@
-- 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 api = vim.api
local uv = vim.uv or 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 = {}
---@param a uv.fs_stat.result?
---@param b uv.fs_stat.result?
---@return boolean equal Whether a and b are stats of the same file
local same_file = function(a, b)
return a and b and a.dev == b.dev and a.ino == b.ino or false
end
---Checks to see if a file can safely be renamed to its destination without data loss.
---Also prevents renames from going through if the rename will not do anything.
---Has an additional check for case-insensitive filesystems (e.g. for windows)
---@param source string
---@param destination string
---@return boolean rename_is_safe
local function rename_is_safe(source, destination)
local destination_file = uv.fs_stat(destination)
if not destination_file then
return true
end
local src = utils.normalize_path(source)
local dest = utils.normalize_path(destination)
local changing_casing = src ~= dest and src:lower() == dest:lower()
if changing_casing then
local src_file = uv.fs_stat(src)
-- We check that the two paths resolve to the same canonical filename and file.
return same_file(src_file, destination_file)
and uv.fs_realpath(src) == uv.fs_realpath(destination)
end
return false
end
local function find_replacement_buffer(for_buf)
local bufs = vim.api.nvim_list_bufs()
-- make sure the alternate buffer is at the top of the list
local alt = vim.fn.bufnr("#")
if alt ~= -1 and alt ~= for_buf then
table.insert(bufs, 1, alt)
end
-- find the first valid real file buffer
for _, buf in ipairs(bufs) do
if buf ~= for_buf then
local is_valid = vim.api.nvim_buf_is_valid(buf)
if is_valid then
local buftype = vim.bo[buf].buftype
if buftype == "" then
return buf
end
end
end
end
return -1
end
local function clear_buffer(path)
local buf = utils.find_buffer_by_name(path)
if buf < 1 then
return
end
local alt = find_replacement_buffer(buf)
-- 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.bo[new_buf].buflisted = true
replace_buffer_in_windows(buf, new_buf)
if vim.bo[buf].buftype == "" then
local modified = vim.bo[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 function create_all_as_folders(in_path)
if not uv.fs_stat(in_path) then
local parent, _ = utils.split_path(in_path)
if parent then
create_all_as_folders(parent)
end
uv.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.
---@param source string
---@param destination string
---@param using_root_directory boolean
---@param name_chosen_callback fun(string)
---@param first_message string?
local function get_unused_name(
source,
destination,
using_root_directory,
name_chosen_callback,
first_message
)
if not rename_is_safe(source, 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(source, 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(source, destination or source, using_root_directory, function(dest)
-- Resolve user-inputted relative paths out of the absolute paths
dest = vim.fs.normalize(dest)
if utils.is_windows then
dest = utils.windowize_path(dest)
end
local function move_file()
create_all_parents(dest)
uv.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(source, _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 event_result = events.fire_event(events.BEFORE_FILE_ADD, destination) or {}
if event_result.handled then
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
local event_result = events.fire_event(events.BEFORE_FILE_ADD, destination) or {}
if event_result.handled then
return
end
if uv.fs_stat(destination) then
log.warn("Directory already exists")
return
end
create_all_parents(destination)
uv.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
local dir_ending = '"/"'
if utils.path_separator ~= "/" then
dir_ending = dir_ending .. string.format(' or "%s"', utils.path_separator)
end
local msg = "Enter name for new file or directory (dirs end with a " .. dir_ending .. "):"
inputs.input(msg, 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, "/")
or vim.endswith(destination, utils.path_separator)
if using_root_directory then
destination = utils.path_join(using_root_directory, destination)
else
destination = vim.fn.fnamemodify(destination, ":p")
end
destination = utils.normalize_path(destination)
if uv.fs_stat(destination) then
log.warn("File already exists")
return
end
local complete = vim.schedule_wrap(function()
events.fire_event(events.FILE_ADDED, destination)
if callback then
callback(destination)
end
end)
local event_result = events.fire_event(events.BEFORE_FILE_ADD, destination) or {}
if event_result.handled then
complete()
return
end
create_all_parents(destination)
if is_dir then
uv.fs_mkdir(destination, 493)
else
local open_mode = uv.constants.O_CREAT + uv.constants.O_WRONLY + uv.constants.O_TRUNC
local fd = uv.fs_open(destination, open_mode, 420)
if not fd then
if not uv.fs_stat(destination) then
log.error("Could not create file " .. destination)
return
else
log.warn("Failed to complete file creation of " .. destination)
end
else
uv.fs_close(fd)
end
end
complete()
end
end)
end
---Recursively delete a directory and its children.
---@param dir_path string Directory to delete.
---@return boolean success Whether the directory was deleted.
local function delete_dir(dir_path)
local handle = uv.fs_scandir(dir_path)
if type(handle) == "string" then
log.error(handle)
return false
end
if not handle then
log.error("could not scan dir " .. dir_path)
return false
end
while true do
local child_name, t = uv.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 = uv.fs_unlink(child_path)
if not success then
return false
end
clear_buffer(child_path)
end
end
return uv.fs_rmdir(dir_path) or false
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 = uv.fs_stat(path)
if stat then
_type = stat.type
if _type == "link" then
local link_to = uv.fs_readlink(path)
if not link_to then
log.error("Could not read link")
return
end
local target_file = uv.fs_stat(link_to)
if target_file then
_type = target_file.type
end
_type = uv.fs_stat(link_to).type
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()
local complete = vim.schedule_wrap(function()
events.fire_event(events.FILE_DELETED, path)
if callback then
callback(path)
end
end)
local event_result = events.fire_event(events.BEFORE_FILE_DELETE, path) or {}
if event_result.handled then
complete()
return
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", vim.fn.shellescape(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 log.error("Could not remove directory: " .. path)
end
end
else
local success = uv.fs_unlink(path)
if not success then
return log.error("Could not remove file: " .. path)
end
clear_buffer(path)
end
complete()
end
if noconfirm then
do_delete()
else
inputs.confirm(msg, function(confirmed)
if confirmed then
do_delete()
end
end)
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
local rename_node = function(msg, name, get_destination, path, callback)
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 = get_destination(new_name)
if not rename_is_safe(path, destination) then
log.warn(destination, " already exists, canceling")
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()
uv.fs_rename(path, destination, function(err)
if err then
log.warn("Could not rename the files")
return
end
complete()
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
complete()
return
end
fs_rename()
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)
local get_destination = function(new_name)
return parent_path .. utils.path_separator .. new_name
end
rename_node(msg, name, get_destination, path, callback)
end
-- Rename Node Base Name
M.rename_node_basename = function(path, callback)
local parent_path, name = utils.split_path(path)
local base_name = vim.fn.fnamemodify(path, ":t:r")
local extension = vim.fn.fnamemodify(path, ":e")
local msg = string.format('Enter new base name for "%s":', name)
local get_destination = function(new_base_name)
return parent_path
.. utils.path_separator
.. new_base_name
.. (extension:len() == 0 and "" or "." .. extension)
end
rename_node(msg, base_name, get_destination, path, callback)
end
return M

View file

@ -0,0 +1,738 @@
-- This files holds code for scanning the filesystem to build the tree.
local uv = vim.uv or 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 file_nesting = require("neo-tree.sources.common.file-nesting")
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 M = {}
--- how many entries to load per readdir
local ENTRIES_BATCH_SIZE = 1000
---@param context neotree.sources.filesystem.Context
---@param dir_path string
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
---@param context neotree.sources.filesystem.Context
---@param dir_path string
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(uv.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
---@param context neotree.sources.filesystem.Context
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.advanced_sort(root.children, state)
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
---@diagnostic disable-next-line: cast-local-type
context = nil
end
---@param context neotree.sources.filesystem.Context
local should_check_gitignore = function(context)
local state = context.state
if #context.all_items == 0 then
log.info("No items, skipping git ignored/status lookups")
return false
end
if state.search_pattern and state.check_gitignore_in_search == false then
return false
end
if state.filtered_items.hide_gitignored then
return true
end
if state.enable_git_status == false then
return false
end
return true
end
local job_complete_async = function(context)
local state = context.state
local parent_id = context.parent_id
file_nesting.nest_items(context)
-- if state.search_pattern and #context.all_items > 50 then
-- -- don't do git ignored/status lookups when searching unless we are down to a reasonable number of items
-- return context
-- end
if should_check_gitignore(context) then
local mark_ignored_async = async.wrap(function(_state, _all_items, _callback)
git.mark_ignored(_state, _all_items, _callback)
end, 3)
local all_items = mark_ignored_async(state, context.all_items)
if parent_id then
vim.list_extend(state.git_ignored, all_items)
else
state.git_ignored = all_items
end
end
return context
end
local job_complete = function(context)
local state = context.state
local parent_id = context.parent_id
file_nesting.nest_items(context)
if should_check_gitignore(context) 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
render_context(context)
else
render_context(context)
end
end
local function create_node(context, node)
pcall(file_items.create_item, context, node.path, node.type)
end
local function process_node(context, path)
on_directory_loaded(context, path)
end
---@param err string libuv error
---@return boolean is_permission_error
local function is_permission_error(err)
-- Permission errors may be common when scanning over lots of folders;
-- this is used to check for them and log to `debug` instead of `error`.
return vim.startswith(err, "EPERM") or vim.startswith(err, "EACCES")
end
local function get_children_sync(path)
local children = {}
local dir, err = uv.fs_opendir(path, nil, ENTRIES_BATCH_SIZE)
if not dir then
---@cast err -nil
if is_permission_error(err) then
log.debug(err)
else
log.error(err)
end
return children
end
repeat
local stats = uv.fs_readdir(dir)
if not stats then
break
end
local more = false
for i, stat in ipairs(stats) do
more = i == ENTRIES_BATCH_SIZE
local child_path = utils.path_join(path, stat.name)
table.insert(children, { path = child_path, type = stat.type })
end
until not more
uv.fs_closedir(dir)
return children
end
local function get_children_async(path, callback)
local children = {}
uv.fs_opendir(path, function(err, dir)
if err then
if is_permission_error(err) then
log.debug(err)
else
log.error(err)
end
callback(children)
return
end
local readdir_batch
---@param _ string?
---@param stats uv.fs_readdir.entry[]
readdir_batch = function(_, stats)
if stats then
local more = false
for i, stat in ipairs(stats) do
more = i == ENTRIES_BATCH_SIZE
local child_path = utils.path_join(path, stat.name)
table.insert(children, { path = child_path, type = stat.type })
end
if more then
return uv.fs_readdir(dir, readdir_batch)
end
end
uv.fs_closedir(dir)
callback(children)
end
uv.fs_readdir(dir, readdir_batch)
end, ENTRIES_BATCH_SIZE)
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")
or context.recursive
then
scan_dir_sync(context, child.path)
end
end
end
end
--- async method
local function scan_dir_async(context, path)
log.debug("scan_dir_async - start " .. path)
local get_children = async.wrap(function(_path, callback)
return get_children_async(_path, callback)
end, 2)
local children = get_children(path)
for _, child in ipairs(children) do
create_node(context, child)
if child.type == "directory" then
local grandchild_nodes = get_children(child.path)
if
grandchild_nodes == nil
or #grandchild_nodes == 0
or (#grandchild_nodes == 1 and grandchild_nodes[1].type == "directory")
or context.recursive
then
scan_dir_async(context, child.path)
end
end
end
process_node(context, path)
log.debug("scan_dir_async - finish " .. path)
return path
end
-- async_scan scans all the directories in context.paths_to_load
-- and adds them as items to render in the UI.
---@param context neotree.sources.filesystem.Context
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 = function()
scan_dir_async(context, p)
end
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
---@param context neotree.sources.filesystem.Context
---@param path_to_scan string
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 dir, err = uv.fs_opendir(path_to_scan, nil, ENTRIES_BATCH_SIZE)
if dir then
repeat
local stats = uv.fs_readdir(dir)
if not stats then
break
end
local more = false
for i, stat in ipairs(stats) do
more = i == ENTRIES_BATCH_SIZE
local path = utils.path_join(path_to_scan, stat.name)
local success, _ = pcall(file_items.create_item, context, path, stat.type)
if success 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
until not more
uv.fs_closedir(dir)
else
log.error("Error opening dir:", err)
end
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
---@param state neotree.sources.filesystem.State
---@param parent_id string?
---@param path_to_reveal string?
---@param callback function
M.get_items_sync = function(state, parent_id, path_to_reveal, callback)
M.get_items(state, parent_id, path_to_reveal, callback, false)
end
---@param state neotree.sources.filesystem.State
---@param parent_id string?
---@param path_to_reveal string?
---@param callback function
M.get_items_async = function(state, parent_id, path_to_reveal, callback)
M.get_items(state, parent_id, path_to_reveal, callback, true)
end
---@param context neotree.sources.filesystem.Context
local handle_search_pattern = function(context)
local state = context.state
local root = context.root
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
end
---@param context neotree.sources.filesystem.Context
---@param async_dir_scan boolean
local handle_refresh_or_up = function(context, async_dir_scan)
local parent_id = context.parent_id
local path_to_reveal = context.path_to_reveal
local state = context.state
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 parents of all expanded nodes are also scanned
if #context.paths_to_load > 0 and state.tree then
---@type table<string, boolean?>
local seen = {}
for _, p in ipairs(context.paths_to_load) do
---@type string?
local current = p
while current do
if seen[current] then
break
end
seen[current] = true
local current_node = state.tree:get_node(current)
current = current_node and current_node:get_parent_id()
end
end
context.paths_to_load = vim.tbl_keys(seen)
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 = uv.fs_stat(p)
return stats and stats.type == "directory" or false
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_dir_scan then
async_scan(context, path)
else
sync_scan(context, path)
end
end
---@class neotree.sources.filesystem.Context : neotree.FileItemContext
---@field state neotree.sources.filesystem.State
---@field recursive boolean?
---@field parent_id string?
---@field callback function?
---@field async boolean?
---@field root neotree.FileItem.Directory|neotree.FileItem.Link
---@field directories_scanned integer?
---@field directories_to_scan integer?
---@field on_exit function?
---async
---@field paths_to_load string[]
---@field is_a_never_show_file fun(filename: string?):boolean
---@class neotree.sources.filesystem.State : neotree.StateWithTree, neotree.Config.Filesystem
---@field path string
---@param state neotree.sources.filesystem.State
---@param parent_id string?
---@param callback function?
---@param async_dir_scan boolean?
---@param recursive boolean?
M.get_items = function(state, parent_id, path_to_reveal, callback, async_dir_scan, recursive)
renderer.acquire_window(state)
if state.async_directory_scan == "always" then
async_dir_scan = true
elseif state.async_directory_scan == "never" then
async_dir_scan = false
elseif type(async_dir_scan) == "nil" then
async_dir_scan = (state.async_directory_scan == "auto") or state.async_directory_scan ~= nil
end
if not parent_id then
M.stop_watchers(state)
end
---@type neotree.sources.filesystem.Context
local context = file_items.create_context() --[[@as neotree.sources.filesystem.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") --[[@as neotree.FileItem.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
handle_search_pattern(context)
else
-- In the case of a refresh or navigating up, we need to make sure that all
-- open folders are loaded.
handle_refresh_or_up(context, async_dir_scan)
end
end
---@param state neotree.sources.filesystem.State
---@param parent_id string
---@param recursive boolean?
M.get_dir_items_async = function(state, parent_id, recursive)
local context = file_items.create_context() --[[@as neotree.sources.filesystem.Context]]
context.state = state
context.parent_id = parent_id
context.path_to_reveal = nil
context.recursive = recursive
context.callback = nil
context.paths_to_load = {}
-- Create root folder
local root = file_items.create_item(context, parent_id or state.path, "directory") --[[@as neotree.FileItem.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 }
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, parent_id)
local scan_tasks = {}
for _, p in ipairs(context.paths_to_load) do
local scan_task = function()
scan_dir_async(context, p)
end
table.insert(scan_tasks, scan_task)
end
async.util.join(scan_tasks)
job_complete_async(context)
local finalize = async.wrap(function(_context, _callback)
vim.schedule(function()
render_context(_context)
_callback()
end)
end, 2)
finalize(context)
end
---@param state neotree.sources.filesystem.State
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

View file

@ -0,0 +1,177 @@
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 uv = vim.uv or vim.loop
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 = uv.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 = uv.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 = uv.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

View file

@ -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

View file

@ -0,0 +1,74 @@
--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")
---@class neotree.sources.GitStatus.Commands : neotree.sources.Common.Commands
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
---@type neotree.TreeCommandVisual
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
---@type neotree.TreeCommandVisual
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
---@type neotree.TreeCommandVisual
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

View file

@ -0,0 +1,56 @@
-- 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")
---@alias neotree.Component.GitStatus._Key
---|"name"
---@class neotree.Component.GitStatus
---@field [1] neotree.Component.GitStatus._Key|neotree.Component.Common._Key
---@type table<neotree.Component.GitStatus._Key, neotree.Renderer>
local M = {}
---@class (exact) neotree.Component.GitStatus.Name : neotree.Component.Common.Name
---@field [1] "current_filter"?
---@field use_git_status_colors boolean?
---@param config neotree.Component.GitStatus.Name
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)

View file

@ -0,0 +1,111 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
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")
---@class neotree.sources.GitStatus : neotree.Source
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, callback, async)
state.path = path or state.path
state.dirty = false
if path_to_reveal then
renderer.position.set(state, path_to_reveal)
end
items.get_git_status(state)
if type(callback) == "function" then
vim.schedule(callback)
end
end
M.refresh = function()
manager.refresh(M.name)
end
---@class neotree.Config.GitStatus.Renderers : neotree.Config.Renderers
---@class (exact) neotree.Config.GitStatus : neotree.Config.Source
---@field bind_to_cwd boolean?
---@field renderers neotree.Config.GitStatus.Renderers?
---Configures the plugin, should be called before the plugin is used.
---@param config neotree.Config.GitStatus 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.STATE_CREATED,
handler = function(state)
state.diagnostics_lookup = utils.get_diagnostic_counts()
end,
})
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

View file

@ -0,0 +1,49 @@
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'.
---@param state neotree.State
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)
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") --[[@as neotree.FileItem.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") --[[@as neotree.FileItem.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.advanced_sort(root.children, state)
renderer.show_nodes({ root }, state)
state.loading = false
end
return M

View file

@ -0,0 +1,772 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
local nt = require("neo-tree")
local utils = require("neo-tree.utils")
local compat = require("neo-tree.utils._compat")
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 = {}
---@type table<string, neotree.SourceData>
local source_data = {}
---@type neotree.State[]
local all_states = {}
---@type table<string, neotree.Config.Source?>
local default_configs = {}
---@class neotree.SourceData
---@field name string
---@field state_by_tab table<integer, neotree.State>
---@field state_by_win table<integer, neotree.State>
---@field subscriptions table
---@field module neotree.Source?
---@param source_name string
---@return neotree.SourceData
local get_source_data = function(source_name)
assert(source_name, "get_source_data: source_name cannot be nil")
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
---@class neotree.State.Window : neotree.Config.Window
---@field win_width integer
---@field last_user_width integer
---@alias neotree.State.Position "top"|"bottom"|"left"|"right"|"current"|"float"
---@alias neotree.Internal.SortFieldProvider fun(node: NuiTree.Node):any
---@class neotree.State : neotree.Config.Source
---@field name string
---@field tabid integer
---@field id integer
---@field bufnr integer?
---@field dirty boolean
---@field position table
---@field git_base string
---@field sort table
---@field clipboard table
---@field current_position neotree.State.Position?
---@field disposed boolean?
---@field winid integer?
---@field path string?
---@field tree NuiTree?
---@field components table<string, neotree.Component>
---private-ish
---@field orig_tree NuiTree?
---@field _ready boolean?
---@field loading boolean?
---window
---@field window neotree.State.Window?
---@field win_width integer?
---@field longest_width_exact integer?
---@field longest_node integer?
---extras
---@field bind_to_cwd boolean?
---@field opened_buffers neotree.utils.OpenedBuffers?
---@field diagnostics_lookup neotree.utils.DiagnosticLookup?
---@field cwd_target neotree.Config.Filesystem.CwdTarget?
---@field sort_field_provider fun(node: NuiTree.Node):any
---@field explicitly_opened_nodes table<string, boolean?>?
---@field filtered_items neotree.Config.Filesystem.FilteredItems?
---@field skip_marker_at_level table<integer, boolean?>?
---@field group_empty_dirs boolean?
---git
---@field git_status_lookup neotree.git.Status?
---optional mapping args
---@field fallback string?
---@field config table?
---internal
---@field default_expanded_nodes NuiTree.Node[]?
---@field force_open_folders string[]?
---@field enable_source_selector boolean?
---@field follow_current_file neotree.Config.Filesystem.FollowCurrentFile?
---lsp
---@field lsp_winid number?
---@field lsp_bufnr number?
---search
---@field search_pattern string?
---@field use_fzy boolean?
---@field fzy_sort_result_scores table<string, integer?>?
---@field fuzzy_finder_mode "directory"|boolean?
---@field open_folders_before_search table?
---sort
---@field sort_function_override neotree.Config.SortFunction?
---keymaps
---@field resolved_mappings table<string, neotree.State.ResolvedMapping?>?
---@field commands table<string, neotree.TreeCommand?>?
---@class (exact) neotree.StateWithTree : neotree.State
---@field tree NuiTree
local a = {}
---@param tabid integer
---@param sd table
---@param winid integer?
---@return neotree.State
local function create_state(tabid, sd, winid)
nt.ensure_config()
local default_config = assert(default_configs[sd.name])
local state = vim.deepcopy(default_config, compat.noref())
---@cast state neotree.State
state.tabid = tabid
state.id = winid or tabid
state.dirty = true
state.position = {}
state.git_base = "HEAD"
state.sort = { label = "Name", direction = 1 }
events.fire_event(events.STATE_CREATED, state)
table.insert(all_states, state)
return state
end
M._get_all_states = function()
return all_states
end
---@param source_name string?
---@param action fun(state: neotree.State)
M._for_each_state = function(source_name, action)
M.dispose_invalid_tabs()
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
---@param source_name string
---@param config neotree.Config.Source
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?
---@param source_name string
---@param tabid integer?
---@param winid integer?
---@return neotree.State
M.get_state = function(source_name, tabid, winid)
assert(source_name, "get_state: source_name cannot be nil")
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
end
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
---Returns the state for the current buffer, assuming it is a neo-tree buffer.
---@param winid number? The window id to use, if nil, the current window is used.
---@return neotree.State? state The state for the current buffer, if it's a neo-tree buffer.
M.get_state_for_window = function(winid)
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
---@param source_name string
M.subscribe = function(source_name, event)
assert(source_name, "subscribe: source_name cannot be nil")
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
---@param source_name string
M.unsubscribe = function(source_name, event)
assert(source_name, "unsubscribe: source_name cannot be nil")
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
---@param source_name string
M.unsubscribe_all = function(source_name)
assert(source_name, "unsubscribe_all: source_name cannot be nil")
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
---@param source_name string
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.
---@param source_name string
---@param args table<string, neotree.utils.DiagnosticCounts?>
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.
---@param source_name string
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.
---@param source_name string
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
---@param state neotree.State
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
---@param state neotree.State
---@return string
M.get_cwd = function(state)
local winid, tabnr = get_params_for_cwd(state)
if winid or tabnr then
local success, cwd = pcall(vim.fn.getcwd, winid, tabnr)
if success then
return cwd
end
end
local success, cwd = pcall(vim.fn.getcwd)
if success then
return cwd
end
local err = cwd
log.debug(err)
return state.path or ""
end
---@param state neotree.State
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
local path = utils.escape_path_for_cmd(state.path)
if winid > 0 then
vim.cmd("lcd " .. path)
elseif tabnr > 0 then
vim.cmd("tcd " .. path)
else
vim.cmd("cd " .. path)
end
end
end
---@param state neotree.State
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
---@param source_name string
---@param tabid integer
M.dispose = function(source_name, tabid)
-- Iterate in reverse because we are removing items during loop
for i = #all_states, 1, -1 do
local state = all_states[i]
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
---@param tabid integer
M.dispose_tab = function(tabid)
if not tabid then
error("dispose_tab: tabid cannot be nil")
end
-- Iterate in reverse because we are removing items during loop
for i = #all_states, 1, -1 do
local state = all_states[i]
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
---@param winid number
M.dispose_window = function(winid)
assert(winid, "dispose_window: winid cannot be nil")
-- Iterate in reverse because we are removing items during loop
for i = #all_states, 1, -1 do
local state = all_states[i]
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
---@param source_name string
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 neotree.State|string 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")
return
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
--- @deprecated
--- To be removed in 4.0. Use:
--- ```lua
--- require("neo-tree.command").execute({ source_name = source_name, action = "focus", reveal = true })` instead
--- ```
M.reveal_current_file = function(source_name, callback, force_cwd)
log.warn(
[[DEPRECATED: use `require("neo-tree.command").execute({ source_name = source_name, action = "focus", reveal = true })` instead]]
)
log.trace("Revealing current file")
local state = M.get_state(source_name)
state.current_position = nil
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
---@deprecated
--- To be removed in 4.0. Use:
--- ```lua
--- require("neo-tree.command").execute({ source_name = source_name, action = "focus", reveal = true, position = "current" }
--- ```
--- instead.
M.reveal_in_split = function(source_name, callback)
log.warn(
[[DEPRECATED: use `require("neo-tree.command").execute({ source_name = source_name, action = "focus", reveal = true, position = "current" })` instead]]
)
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
local validate = require("neo-tree.health.typecheck").validate
---@param source_name string
---@param module neotree.Source
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
validate(source_name, module, function(mod)
validate("navigate", mod.navigate, "function")
validate("setup", mod.setup, "function")
end)
end
---@class neotree.Source
---@field setup fun(config: neotree.Config.Source, global_config: neotree.Config.Base)
---@field navigate fun(state: neotree.State, path: string?, path_to_reveal: string?, callback: function?, async: boolean?)
---Configures the plugin, should be called before the plugin is used.
---@param source_name string Name of the source.
---@param config neotree.Config.Source Configuration table containing merged configuration for the source.
---@param global_config neotree.Config.Base Global configuration table, shared between all sources.
---@param module neotree.Source 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

View file

@ -0,0 +1,16 @@
---@meta
---@alias neotree.Renderer fun(config: table, node: NuiTree.Node, state: neotree.StateWithTree):(neotree.Render.Node|neotree.Render.Node[])
---@alias neotree.FileRenderer fun(config: table, node: neotree.FileNode, state: neotree.StateWithTree):(neotree.Render.Node|neotree.Render.Node[])
---@class (exact) neotree.Render.Node
---@field text string The text to display.
---@field highlight string The highlight for the text.
---@class (exact) neotree.Component
---@field [1] string?
---@field enabled boolean?
---@field highlight string?
---@alias neotree.IconProvider fun(icon: neotree.Render.Node, node: NuiTree.Node, state: neotree.StateWithTree):(neotree.Render.Node|neotree.Render.Node[]|nil)

View file

@ -0,0 +1,151 @@
---@meta
---@class neotree.Config.Mapping.Options
---@field noremap boolean?
---@field nowait boolean?
---@field desc string?
---@class neotree.Config.Window.Command.Configured : neotree.Config.Mapping.Options
---@field [1] string?
---@field command string?
---@field config table?
---@class neotree.Config.Source
---@field window neotree.Config.Window?
---@field renderers neotree.Config.Renderers?
---@field commands table<string, neotree.Config.TreeCommand?>?
---@field before_render fun(state: neotree.State)?
---@class neotree.Config.SourceSelector.Item
---@field source string?
---@field padding integer|{left:integer,right:integer}?
---@field separator string|{left:string,right:string, override?:string}?
---@alias neotree.Config.SourceSelector.Separator.Override
---|"right" # When right and left separators meet, only show the right one.
---|"left" # When right and left separators meet, only show the left one.
---|"active" # Only use the left separator on the left of the active tab, and only the right afterwards.
---|nil # Show both separators.
---@class neotree.Config.SourceSelector.Separator
---@field left string?
---@field right string?
---@field override neotree.Config.SourceSelector.Separator.Override?
---@class neotree.Config.SourceSelector
---@field winbar boolean?
---@field statusline boolean?
---@field show_scrolled_off_parent_node boolean?
---@field sources neotree.Config.SourceSelector.Item[]?
---@field content_layout? "start"|"end"|"center"
---@field tabs_layout? "equal"|"start"|"end"|"center"|"focus"
---@field truncation_character string
---@field tabs_min_width integer?
---@field tabs_max_width integer?
---@field padding integer|{left: integer, right:integer}?
---@field separator neotree.Config.SourceSelector.Separator?
---@field separator_active neotree.Config.SourceSelector.Separator?
---@field show_separator_on_edge boolean?
---@field highlight_tab string?
---@field highlight_tab_active string?
---@field highlight_background string?
---@field highlight_separator string?
---@field highlight_separator_active string?
---@class neotree.Config.GitStatusAsync
---@field batch_size integer?
---@field batch_delay integer?
---@field max_lines integer?
---@class neotree.Config.Window.Size
---@field height string|number?
---@field width string|number?
---@class neotree.Config.Window.Popup
---@field title (fun(state:table):string)?
---@field size neotree.Config.Window.Size?
---@field border neotree.Config.BorderStyle?
---@alias neotree.Config.TreeCommand string|neotree.TreeCommand|neotree.Config.Window.Command.Configured
---@class (exact) neotree.Config.Commands
---@field [string] function
---@class (exact) neotree.Config.Window.Mappings
---@field [string] neotree.Config.TreeCommand?
---@class neotree.Config.Window
---@field position string?
---@field width integer?
---@field height integer?
---@field auto_expand_width boolean?
---@field popup neotree.Config.Window.Popup?
---@field insert_as "child"|"sibling"|nil
---@field mapping_options neotree.Config.Mapping.Options?
---@field mappings neotree.Config.Window.Mappings?
---@class neotree.Config.Renderers
---@field directory neotree.Component.Common[]?
---@field file neotree.Component.Common[]?
---@field message neotree.Component.Common[]?
---@field terminal neotree.Component.Common[]?
---@class neotree.Config.ComponentDefaults
---@field container neotree.Component.Common.Container?
---@field indent neotree.Component.Common.Indent?
---@field icon neotree.Component.Common.Icon?
---@field modified neotree.Component.Common.Modified?
---@field name neotree.Component.Common.Name?
---@field git_status neotree.Component.Common.GitStatus?
---@field file_size neotree.Component.Common.FileSize?
---@field type neotree.Component.Common.Type?
---@field last_modified neotree.Component.Common.LastModified?
---@field created neotree.Component.Common.Created?
---@field symlink_target neotree.Component.Common.SymlinkTarget?
---@alias neotree.Config.BorderStyle "NC"|"rounded"|"single"|"solid"|"double"|""
---@alias neotree.Config.SortFunction fun(a: NuiTree.Node, b: NuiTree.Node):boolean?
---@class (exact) neotree.Config.Base
---@field sources string[]
---@field add_blank_line_at_top boolean
---@field auto_clean_after_session_restore boolean
---@field close_if_last_window boolean
---@field default_source string
---@field enable_diagnostics boolean
---@field enable_git_status boolean
---@field enable_modified_markers boolean
---@field enable_opened_markers boolean
---@field enable_refresh_on_write boolean
---@field enable_cursor_hijack boolean
---@field git_status_async boolean
---@field git_status_async_options neotree.Config.GitStatusAsync
---@field hide_root_node boolean
---@field retain_hidden_root_indent boolean
---@field log_level "trace"|"debug"|"info"|"warn"|"error"|"fatal"|nil
---@field log_to_file boolean|string
---@field open_files_in_last_window boolean
---@field open_files_do_not_replace_types string[]
---@field open_files_using_relative_paths boolean
---@field popup_border_style neotree.Config.BorderStyle
---@field resize_timer_interval integer|-1
---@field sort_case_insensitive boolean
---@field sort_function? neotree.Config.SortFunction
---@field use_popups_for_input boolean
---@field use_default_mappings boolean
---@field source_selector neotree.Config.SourceSelector
---@field event_handlers? neotree.event.Handler[]
---@field default_component_configs neotree.Config.ComponentDefaults
---@field renderers neotree.Config.Renderers
---@field nesting_rules neotree.filenesting.Rule[]
---@field commands table<string, neotree.Config.TreeCommand?>
---@field window neotree.Config.Window
---
---@field filesystem neotree.Config.Filesystem
---@field buffers neotree.Config.Buffers
---@field git_status neotree.Config.GitStatus
---@field document_symbols neotree.Config.DocumentSymbols
---@field bind_to_cwd boolean?
---@class (partial) neotree.Config : neotree.Config.Base

View file

@ -0,0 +1,50 @@
---@meta
---@enum neotree.EventName
local _ = {
AFTER_RENDER = "after_render",
BEFORE_FILE_ADD = "before_file_add",
BEFORE_FILE_DELETE = "before_file_delete",
BEFORE_FILE_MOVE = "before_file_move",
BEFORE_FILE_RENAME = "before_file_rename",
BEFORE_RENDER = "before_render",
FILE_ADDED = "file_added",
FILE_DELETED = "file_deleted",
FILE_MOVED = "file_moved",
FILE_OPENED = "file_opened",
FILE_OPEN_REQUESTED = "file_open_requested",
FILE_RENAMED = "file_renamed",
FS_EVENT = "fs_event",
GIT_EVENT = "git_event",
GIT_STATUS_CHANGED = "git_status_changed",
STATE_CREATED = "state_created",
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_POPUP_INPUT_READY = "neo_tree_popup_input_ready",
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",
}

View file

@ -0,0 +1,11 @@
---@meta
--- A backport from nightly for v0.10 type checking
--- @class neotree._vim.api.keyset.create_autocmd.callback_args
--- @field id integer autocommand id
--- @field event string name of the triggered event |autocmd-events|
--- @field group? integer autocommand group id, if any
--- @field match string expanded value of <amatch>
--- @field buf integer expanded value of <abuf>
--- @field file string expanded value of <afile>
--- @field data? any arbitrary data passed from |nvim_exec_autocmds()| *event-data*

View file

@ -0,0 +1,23 @@
---@meta
---@class uv
---@field constants {O_RDONLY: integer, O_WRONLY: integer, O_RDWR: integer, O_APPEND: integer, O_CREAT: integer, O_DSYNC: integer, O_EXCL: integer, O_NOCTTY: integer, O_NONBLOCK: integer, O_RSYNC: integer, O_SYNC: integer, O_TRUNC: integer, SOCK_STREAM: integer, SOCK_DGRAM: integer, SOCK_SEQPACKET: integer, SOCK_RAW: integer, SOCK_RDM: integer, AF_UNIX: integer, AF_INET: integer, AF_INET6: integer, AF_IPX: integer, AF_NETLINK: integer, AF_X25: integer, AF_AX25: integer, AF_ATMPVC: integer, AF_APPLETALK: integer, AF_PACKET: integer, AI_ADDRCONFIG: integer, AI_V4MAPPED: integer, AI_ALL: integer, AI_NUMERICHOST: integer, AI_PASSIVE: integer, AI_NUMERICSERV: integer, SIGHUP: integer, SIGINT: integer, SIGQUIT: integer, SIGILL: integer, SIGTRAP: integer, SIGABRT: integer, SIGIOT: integer, SIGBUS: integer, SIGFPE: integer, SIGKILL: integer, SIGUSR1: integer, SIGSEGV: integer, SIGUSR2: integer, SIGPIPE: integer, SIGALRM: integer, SIGTERM: integer, SIGCHLD: integer, SIGSTKFLT: integer, SIGCONT: integer, SIGSTOP: integer, SIGTSTP: integer, SIGTTIN: integer, SIGWINCH: integer, SIGIO: integer, SIGPOLL: integer, SIGXFSZ: integer, SIGVTALRM: integer, SIGPROF: integer, UDP_RECVMMSG: integer, UDP_MMSG_CHUNK: integer, UDP_REUSEADDR: integer, UDP_PARTIAL: integer, UDP_IPV6ONLY: integer, TCP_IPV6ONLY: integer, UDP_MMSG_FREE: integer, SIGSYS: integer, SIGPWR: integer, SIGTTOU: integer, SIGURG: integer, SIGXCPU: integer}
local uv = {}
--- Opens path as a directory stream. Returns a handle that the user can pass to
--- `uv.fs_readdir()`. The `entries` parameter defines the maximum number of entries
--- that should be returned by each call to `uv.fs_readdir()`.
---
--- **Returns (sync version):** `luv_dir_t userdata` or `fail`
---
--- **Returns (async version):** `uv_fs_t userdata`
---
---@param path string
---@param callback nil
---@param entries integer?
---@return uv.luv_dir_t|nil dir
---@return uv.error.message|nil err
---@return uv.error.name|nil err_name
---
---@overload fun(path: string, callback: uv.fs_opendir.callback, entries?: integer):uv.uv_fs_t
function uv.fs_opendir(path, callback, entries) end

View file

@ -0,0 +1,324 @@
local log = require("neo-tree.log")
local utils = require("neo-tree.utils")
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.FILE_STATS = "NeoTreeFileStats"
M.FILE_STATS_HEADER = "NeoTreeFileStatsHeader"
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.EXPANDER = "NeoTreeExpander"
M.WINDOWS_HIDDEN = "NeoTreeWindowsHidden"
M.PREVIEW = "NeoTreePreview"
---@param n integer
---@param chars integer?
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
---@param name string
local get_hl_by_name = function(name)
if vim.api.nvim_get_hl then
local hl = vim.api.nvim_get_hl(0, { name = name })
---@diagnostic disable-next-line: inject-field
hl.foreground = hl.fg
---@diagnostic disable-next-line: inject-field
hl.background = hl.bg
return hl
end
---TODO: remove in 4.0
---@diagnostic disable-next-line: deprecated
return vim.api.nvim_get_hl_by_name(name, true)
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 string[] A list of highlight groups to link to, in order of priority. The first one that exists will be used.
---@param background string? 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? The foreground color to use, in hex, if the highlight group is not defined and it is not linked to another group.
---@param gui string? The gui to use, if the highlight group is not defined and it is not linked to another group.
---@return table hlgroups The highlight group values.
M.create_highlight_group = function(hl_group_name, link_to_if_exists, background, foreground, gui)
local success, hl_group = pcall(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(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
---@param hl_group_name string
---@param fade_percentage number
local calculate_faded_highlight_group = function(hl_group_name, fade_percentage)
local normal = get_hl_by_name("Normal")
if type(normal.foreground) ~= "number" then
if vim.go.background == "dark" then
normal.foreground = 0xffffff
else
normal.foreground = 0x000000
end
end
if type(normal.background) ~= "number" then
if vim.go.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 = get_hl_by_name(hl_group_name)
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
local hl
if #gui > 0 then
hl = table.concat(gui, ",")
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))
return {
background = hl_group.background,
foreground = new_foreground,
gui = hl,
}
end
local faded_highlight_group_cache = {}
---@param hl_group_name string
---@param fade_percentage number
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 faded = calculate_faded_highlight_group(hl_group_name, fade_percentage)
M.create_highlight_group(key, {}, faded.background, faded.foreground, faded.gui)
faded_highlight_group_cache[key] = key
return key
end
local nvim_0_10 = vim.fn.has("nvim-0.10")
M.setup = function()
local added_hl_name = nvim_0_10 and "Added" or "diffAdded"
local changed_hl_name = nvim_0_10 and "Changed" or "diffChanged"
local removed_hl_name = nvim_0_10 and "Removed" or "diffRemoved"
-- 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)
local dim_text = calculate_faded_highlight_group("NeoTreeNormal", 0.3)
M.create_highlight_group(M.BUFFER_NUMBER, { "SpecialChar" })
--M.create_highlight_group(M.DIM_TEXT, {}, nil, "505050")
M.create_highlight_group(M.MESSAGE, {}, nil, dim_text.foreground, "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.DIM_TEXT, {}, nil, dim_text.foreground)
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", added_hl_name },
nil,
"5faf5f"
)
M.create_highlight_group(
M.GIT_DELETED,
{ "GitGutterDelete", "GitSignsDelete", removed_hl_name },
nil,
"ff5900"
)
M.create_highlight_group(
M.GIT_MODIFIED,
{ "GitGutterChange", "GitSignsChange", changed_hl_name },
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")
local faded_normal = calculate_faded_highlight_group("NeoTreeNormal", 0.4)
M.create_highlight_group(M.FILE_STATS, {}, nil, faded_normal.foreground)
local faded_root = calculate_faded_highlight_group("NeoTreeRootName", 0.5)
M.create_highlight_group(M.FILE_STATS_HEADER, {}, nil, faded_root.foreground, faded_root.gui)
end
return M

View file

@ -0,0 +1,109 @@
local NuiInput = require("nui.input")
local nt = require("neo-tree")
local popups = require("neo-tree.ui.popups")
local events = require("neo-tree.events")
local M = {}
---@param input NuiInput
---@param callback function?
M.show_input = function(input, callback)
input:mount()
input:map("i", "<esc>", function()
vim.cmd("stopinsert")
input:unmount()
end, { noremap = true })
input:map("n", "<esc>", function()
input:unmount()
end, { noremap = true })
input:map("n", "q", function()
input:unmount()
end, { noremap = true })
input:map("i", "<C-w>", "<C-S-w>", { 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 })
if input.prompt_type ~= "confirm" then
vim.schedule(function()
events.fire_event(events.NEO_TREE_POPUP_INPUT_READY, {
bufnr = input.bufnr,
winid = input.winid,
})
end)
end
end
---@param message string
---@param default_value string?
---@param callback function
---@param options nui_popup_options?
---@param completion string?
M.input = function(message, default_value, callback, options, completion)
if nt.config.use_popups_for_input then
local popup_options = popups.popup_options(message, 10, options)
local input = NuiInput(popup_options, {
prompt = " ",
default_value = default_value,
on_submit = callback,
})
M.show_input(input)
else
local opts = {
prompt = message .. "\n",
default = default_value,
}
if vim.opt.cmdheight:get() == 0 then
-- NOTE: I really don't know why but letters before the first '\n' is not rendered except in noice.nvim
-- when vim.opt.cmdheight = 0 <2023-10-24, pysan3>
opts.prompt = "Neo-tree Popup\n" .. opts.prompt
end
if completion then
opts.completion = completion
end
vim.ui.input(opts, callback)
end
end
---Blocks if callback is omitted
---@param message string
---@param callback? fun(confirmed: boolean)
---@return boolean? confirmed_if_no_callback
M.confirm = function(message, callback)
if callback then
if nt.config.use_popups_for_input then
local popup_options = popups.popup_options(message, 10)
---@class NuiInput
local input = NuiInput(popup_options, {
prompt = " y/n: ",
on_close = function()
callback(false)
end,
on_submit = function(value)
callback(value == "y" or value == "Y")
end,
})
input.prompt_type = "confirm"
M.show_input(input)
else
callback(vim.fn.confirm(message, "&Yes\n&No") == 1)
end
else
return vim.fn.confirm(message, "&Yes\n&No") == 1
end
end
return M

View file

@ -0,0 +1,147 @@
local NuiText = require("nui.text")
local NuiPopup = require("nui.popup")
local nt = require("neo-tree")
local highlights = require("neo-tree.ui.highlights")
local log = require("neo-tree.log")
local M = {}
local winborder_option_exists = vim.fn.exists("&winborder") > 0
-- These borders will cause errors when trying to display border text with them
local invalid_borders = { "", "none", "shadow" }
---@param title string
---@param min_width integer?
---@param override_options table?
M.popup_options = function(title, min_width, override_options)
if string.len(title) ~= 0 then
title = " " .. title .. " "
end
min_width = min_width or 30
local width = string.len(title) + 2
local popup_border_style = nt.config.popup_border_style
if popup_border_style == "" then
-- Try to use winborder
if not winborder_option_exists or vim.tbl_contains(invalid_borders, vim.o.winborder) then
popup_border_style = "single"
else
---@diagnostic disable-next-line: cast-local-type
popup_border_style = vim.o.winborder
end
end
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
---@type nui_popup_options
local popup_options = {
ns_id = highlights.ns_id,
relative = "cursor",
position = {
row = 1,
col = col,
},
size = width,
border = {
text = {
top = popup_border_text,
},
---@diagnostic disable-next-line: assign-type-mismatch
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
---@param title string
---@param message elem_or_list<string|integer>
---@param size integer?
M.alert = function(title, message, size)
local lines = {}
local max_line_width = title:len()
---@param line any
local add_line = function(line)
line = tostring(line)
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 <Escape> or <Enter> 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", "<esc>", function()
win:unmount()
end, { noremap = true })
win:map("n", "<enter>", function()
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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,405 @@
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
---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 (utils.truncate_by_cell(text, output_width))
else
return (utils.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 = assert(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 neotree.State:
---@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 = config.source_selector.padding
local padding
if type(padding_config) == "number" then
padding = { left = padding_config, right = padding_config }
else
padding = padding_config or { left = 0, right = 0 }
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 or {}
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 or "equal"
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 then -- not enough width
tabs_layout = "equal" -- other methods cannot handle this
end
if tabs_layout == "active" then
local active_tab_length = width - length_sum + length_active - 1
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 neotree.State: state
---@return nil
M.set_source_selector = function(state)
if state.enable_source_selector == false then
return
end
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 or {}
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

View file

@ -0,0 +1,27 @@
local locations = {}
locations.get_location = function(location)
local tab = vim.api.nvim_get_current_tabpage()
if not locations[tab] then
locations[tab] = {}
end
local loc = locations[tab][location]
if loc then
if loc.winid ~= 0 then
-- verify the window before we return it
if not vim.api.nvim_win_is_valid(loc.winid) then
loc.winid = 0
end
end
return loc
end
loc = {
source = nil,
name = location,
winid = 0,
}
locations[tab][location] = loc
return loc
end
return locations

View file

@ -0,0 +1,42 @@
local compat = {}
---@return boolean
compat.noref = function()
return vim.fn.has("nvim-0.10") == 1 and true or {} --[[@as boolean]]
end
---source: https://github.com/Validark/Lua-table-functions/blob/master/table.lua
---Moves elements [f, e] from array a1 into a2 starting at index t
---table.move implementation
---@generic T: table
---@param a1 T from which to draw elements from range
---@param f integer starting index for range
---@param e integer ending index for range
---@param t integer starting index to move elements from a1 within [f, e]
---@param a2 T the second table to move these elements to
---@default a2 = a1
---@returns a2
local table_move = function(a1, f, e, t, a2)
a2 = a2 or a1
t = t + e
for i = e, f, -1 do
t = t - 1
a2[t] = a1[i]
end
return a2
end
---source:
compat.table_move = table.move or table_move
---@vararg any
local table_pack = function(...)
-- Returns a new table with parameters stored into an array, with field "n" being the total number of parameters
local t = { ... }
---@diagnostic disable-next-line: inject-field
t.n = #t
return t
end
compat.table_pack = table.pack or table_pack
return compat

View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Boris Nagaev
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.

View file

@ -0,0 +1,134 @@
-- lua-filesize, generate a human readable string describing the file size
-- Copyright (c) 2016 Boris Nagaev
-- See the LICENSE file for terms of use.
local si = {
bits = { "b", "Kb", "Mb", "Gb", "Tb", "Pb", "Eb", "Zb", "Yb" },
bytes = { "B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" },
}
local function isNan(num)
-- http://lua-users.org/wiki/InfAndNanComparisons
-- NaN is the only value that doesn't equal itself
return num ~= num
end
local function roundNumber(num, digits)
local fmt = "%." .. digits .. "f"
return tonumber(fmt:format(num))
end
local function filesize(size, options)
-- copy options to o
local o = {}
for key, value in pairs(options or {}) do
o[key] = value
end
local function setDefault(name, default)
if o[name] == nil then
o[name] = default
end
end
setDefault("bits", false)
setDefault("unix", false)
setDefault("base", 2)
setDefault("round", o.unix and 1 or 2)
setDefault("spacer", o.unix and "" or " ")
setDefault("suffixes", {})
setDefault("output", "string")
setDefault("exponent", -1)
assert(not isNan(size), "Invalid arguments")
local ceil = (o.base > 2) and 1000 or 1024
local negative = (size < 0)
if negative then
-- Flipping a negative number to determine the size
size = -size
end
local result
-- Zero is now a special case because bytes divide by 1
if size == 0 then
result = {
0,
o.unix and "" or (o.bits and "b" or "B"),
}
else
-- Determining the exponent
if o.exponent == -1 or isNan(o.exponent) then
o.exponent = math.floor(math.log(size) / math.log(ceil))
end
-- Exceeding supported length, time to reduce & multiply
if o.exponent > 8 then
o.exponent = 8
end
local val
if o.base == 2 then
val = size / math.pow(2, o.exponent * 10)
else
val = size / math.pow(1000, o.exponent)
end
if o.bits then
val = val * 8
if val > ceil then
val = val / ceil
o.exponent = o.exponent + 1
end
end
result = {
roundNumber(val, o.exponent > 0 and o.round or 0),
(o.base == 10 and o.exponent == 1) and (o.bits and "kb" or "kB")
or si[o.bits and "bits" or "bytes"][o.exponent + 1],
}
if o.unix then
result[2] = result[2]:sub(1, 1)
if result[2] == "b" or result[2] == "B" then
result = {
math.floor(result[1]),
"",
}
end
end
end
assert(result)
-- Decorating a 'diff'
if negative then
result[1] = -result[1]
end
-- Applying custom suffix
result[2] = o.suffixes[result[2]] or result[2]
-- Applying custom suffix
result[2] = o.suffixes[result[2]] or result[2]
-- Returning Array, Object, or String (default)
if o.output == "array" then
return result
elseif o.output == "exponent" then
return o.exponent
elseif o.output == "object" then
return {
value = result[1],
suffix = result[2],
}
elseif o.output == "string" then
local value = tostring(result[1])
value = value:gsub("%.0$", "")
local suffix = result[2]
return value .. o.spacer .. suffix
end
end
return filesize

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
[tools]
cargo-binstall = "latest"
"cargo:emmylua_check" = "latest"
"cargo:emmylua_ls" = "latest"
lua-language-server = "latest"

View file

@ -0,0 +1,157 @@
if vim.g.loaded_neo_tree == 1 or vim.g.loaded_neo_tree == true then
return
end
-- Possibly convert this to lua using customlist instead of custom in the future?
vim.api.nvim_create_user_command("Neotree", function(ctx)
require("neo-tree.command")._command(unpack(ctx.fargs))
end, {
nargs = "*",
complete = "custom,v:lua.require'neo-tree.command'.complete_args",
})
---@param path string? The path to check
---@return boolean hijacked Whether we hijacked a buffer
local function try_netrw_hijack(path)
if not path or #path == 0 then
return false
end
local stats = (vim.uv or vim.loop).fs_stat(path)
if not stats or stats.type ~= "directory" then
return false
end
return require("neo-tree.setup.netrw").hijack()
end
local augroup = vim.api.nvim_create_augroup("NeoTree", { clear = true })
-- lazy load until bufenter/netrw hijack
vim.api.nvim_create_autocmd({ "BufEnter" }, {
group = augroup,
desc = "Lazy-load until bufenter/opened dir",
callback = function(args)
return vim.g.neotree_watching_bufenter == 1 or try_netrw_hijack(args.file)
end,
})
-- track window order
vim.api.nvim_create_autocmd({ "WinEnter" }, {
group = augroup,
desc = "Track prior windows for opening intuitiveness",
callback = function(ev)
local win = vim.api.nvim_get_current_win()
local utils = require("neo-tree.utils")
if utils.is_floating(win) then
return
end
if vim.bo[ev.buf].filetype == "neo-tree" then
return
end
local tabid = vim.api.nvim_get_current_tabpage()
utils.prior_windows[tabid] = utils.prior_windows[tabid] or {}
local tab_windows = utils.prior_windows[tabid]
table.insert(tab_windows, win)
-- prune history
local win_count = #tab_windows
if win_count > 100 then
if table.move then
utils.prior_windows[tabid] =
require("neo-tree.utils._compat").table_move(tab_windows, 80, win_count, 1, {})
return
end
local new_array = {}
for i = 80, win_count do
table.insert(new_array, tab_windows[i])
end
utils.prior_windows[tabid] = new_array
end
end,
})
-- setup session loading
vim.api.nvim_create_autocmd("SessionLoadPost", {
group = augroup,
desc = "Session loading",
callback = function()
if require("neo-tree").ensure_config().auto_clean_after_session_restore then
require("neo-tree.ui.renderer").clean_invalid_neotree_buffers(true)
end
end,
})
vim.api.nvim_create_autocmd("WinClosed", {
group = augroup,
desc = "close_if_last_window autocmd",
callback = function(args)
local closing_win = tonumber(args.match)
local visible_winids = vim.api.nvim_tabpage_list_wins(0)
local other_panes = {}
local utils = require("neo-tree.utils")
for _, winid in ipairs(visible_winids) do
if not utils.is_floating(winid) and winid ~= closing_win then
other_panes[#other_panes + 1] = winid
end
end
if #other_panes ~= 1 then
return
end
local remaining_pane = other_panes[1]
local remaining_buf = vim.api.nvim_win_get_buf(remaining_pane)
if vim.bo[remaining_buf].filetype ~= "neo-tree" then
return
end
local position = vim.b[remaining_buf].neo_tree_position
local source = vim.b[remaining_buf].neo_tree_source
-- close_if_last_window just doesn't make sense for a split style
if position == "current" then
return
end
local log = require("neo-tree.log")
log.trace("last window, closing")
local state = require("neo-tree.sources.manager").get_state(source)
if not state then
return
end
if not require("neo-tree").ensure_config().close_if_last_window 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(0, state.window.width or 40)
vim.cmd("b " .. buf_name)
end)
return
end
end
vim.cmd("qa!")
end,
})
vim.g.loaded_neo_tree = 1

View file

@ -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

View file

@ -0,0 +1,13 @@
local root_dir = vim.fs.find("neo-tree.nvim", { upward = true, limit = 1 })[1]
assert(root_dir, "no neo-tree found")
package.path = ("%s;%s/?.lua;%s/?/init.lua"):format(package.path, root_dir, root_dir)
vim.opt.packpath:prepend(root_dir .. "/.dependencies")
vim.opt.rtp = {
root_dir,
vim.env.VIMRUNTIME,
}
-- need this for tests to work
vim.cmd.source(root_dir .. "/plugin/neo-tree.lua")

View file

@ -0,0 +1,107 @@
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
local run_close_command = function(command)
vim.cmd(command)
u.wait_for(function() end, { interval = 200, timeout = 200 })
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
run_close_command(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)

View file

@ -0,0 +1,214 @@
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)
u.wait_for_neo_tree({ interval = 10, timeout = 200 })
--u.wait_for_neo_tree()
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
local run_close_command = function(command)
vim.cmd(command)
u.wait_for(function() end, { interval = 200, timeout = 200 })
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
run_close_command(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
run_close_command("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 = {
enabled = follow_current_file,
},
},
})
describe(string.format("w/ follow_current_file.enabled=%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
run_close_command(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
-- toggle CLOSE
run_close_command(cmd)
verify.after(500, function()
-- 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)

View file

@ -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)

Some files were not shown because too many files have changed in this diff Show more