From fefd6ad5e48ff5fcd04fa76d1410a65c40376964 Mon Sep 17 00:00:00 2001 From: Steven Arcangeli Date: Thu, 15 Dec 2022 02:24:27 -0800 Subject: [PATCH] feat: first draft --- .envrc | 1 + .github/generate.py | 177 ++++++++ .github/main.py | 31 ++ .github/nvim_doc_tools | 1 + .github/pre-commit | 5 + .github/workflows/install_nvim.sh | 12 + .github/workflows/tests.yml | 54 +++ .github/workflows/update-docs.yml | 36 ++ .gitignore | 2 + .gitmodules | 3 + .luacheckrc | 19 + .stylua.toml | 3 + README.md | 334 +++++++++++++- doc/oil.txt | 251 +++++++++++ doc/tags | 37 ++ lua/oil/actions.lua | 87 ++++ lua/oil/adapters/files.lua | 406 +++++++++++++++++ lua/oil/adapters/files/permissions.lua | 103 +++++ lua/oil/adapters/ssh.lua | 454 +++++++++++++++++++ lua/oil/adapters/ssh/connection.lua | 270 ++++++++++++ lua/oil/adapters/test.lua | 62 +++ lua/oil/cache.lua | 183 ++++++++ lua/oil/columns.lua | 238 ++++++++++ lua/oil/config.lua | 154 +++++++ lua/oil/constants.lua | 10 + lua/oil/fs.lua | 256 +++++++++++ lua/oil/init.lua | 582 +++++++++++++++++++++++++ lua/oil/keymap_util.lua | 100 +++++ lua/oil/loading.lua | 61 +++ lua/oil/mutator/disclaimer.lua | 71 +++ lua/oil/mutator/init.lua | 507 +++++++++++++++++++++ lua/oil/mutator/parser.lua | 225 ++++++++++ lua/oil/mutator/preview.lua | 130 ++++++ lua/oil/mutator/progress.lua | 77 ++++ lua/oil/mutator/trie.lua | 153 +++++++ lua/oil/pathutil.lua | 37 ++ lua/oil/repl_layout.lua | 140 ++++++ lua/oil/shell.lua | 40 ++ lua/oil/util.lua | 500 +++++++++++++++++++++ lua/oil/view.lua | 430 ++++++++++++++++++ run_tests.sh | 24 + syntax/oil.vim | 7 + syntax/oil_preview.vim | 11 + tests/files_spec.lua | 311 +++++++++++++ tests/minimal_init.lua | 11 + tests/mutator_spec.lua | 540 +++++++++++++++++++++++ tests/path_spec.lua | 32 ++ tests/url_spec.lua | 24 + 48 files changed, 7201 insertions(+), 1 deletion(-) create mode 100644 .envrc create mode 100755 .github/generate.py create mode 100755 .github/main.py create mode 160000 .github/nvim_doc_tools create mode 100755 .github/pre-commit create mode 100644 .github/workflows/install_nvim.sh create mode 100644 .github/workflows/tests.yml create mode 100644 .github/workflows/update-docs.yml create mode 100644 .gitmodules create mode 100644 .luacheckrc create mode 100644 .stylua.toml create mode 100644 doc/oil.txt create mode 100644 doc/tags create mode 100644 lua/oil/actions.lua create mode 100644 lua/oil/adapters/files.lua create mode 100644 lua/oil/adapters/files/permissions.lua create mode 100644 lua/oil/adapters/ssh.lua create mode 100644 lua/oil/adapters/ssh/connection.lua create mode 100644 lua/oil/adapters/test.lua create mode 100644 lua/oil/cache.lua create mode 100644 lua/oil/columns.lua create mode 100644 lua/oil/config.lua create mode 100644 lua/oil/constants.lua create mode 100644 lua/oil/fs.lua create mode 100644 lua/oil/init.lua create mode 100644 lua/oil/keymap_util.lua create mode 100644 lua/oil/loading.lua create mode 100644 lua/oil/mutator/disclaimer.lua create mode 100644 lua/oil/mutator/init.lua create mode 100644 lua/oil/mutator/parser.lua create mode 100644 lua/oil/mutator/preview.lua create mode 100644 lua/oil/mutator/progress.lua create mode 100644 lua/oil/mutator/trie.lua create mode 100644 lua/oil/pathutil.lua create mode 100644 lua/oil/repl_layout.lua create mode 100644 lua/oil/shell.lua create mode 100644 lua/oil/util.lua create mode 100644 lua/oil/view.lua create mode 100755 run_tests.sh create mode 100644 syntax/oil.vim create mode 100644 syntax/oil_preview.vim create mode 100644 tests/files_spec.lua create mode 100644 tests/minimal_init.lua create mode 100644 tests/mutator_spec.lua create mode 100644 tests/path_spec.lua create mode 100644 tests/url_spec.lua diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..175de89 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +layout python diff --git a/.github/generate.py b/.github/generate.py new file mode 100755 index 0000000..a1b7626 --- /dev/null +++ b/.github/generate.py @@ -0,0 +1,177 @@ +import os +from dataclasses import dataclass, field +import os.path +import re +from functools import lru_cache +from typing import List + +from nvim_doc_tools.vimdoc import format_vimdoc_params +from nvim_doc_tools import ( + Command, + LuaParam, + Vimdoc, + VimdocSection, + commands_from_json, + format_md_commands, + format_vimdoc_commands, + generate_md_toc, + indent, + leftright, + parse_functions, + read_nvim_json, + read_section, + render_md_api, + render_vimdoc_api, + replace_section, + wrap, +) + +HERE = os.path.dirname(__file__) +ROOT = os.path.abspath(os.path.join(HERE, os.path.pardir)) +README = os.path.join(ROOT, "README.md") +DOC = os.path.join(ROOT, "doc") +VIMDOC = os.path.join(DOC, "oil.txt") + + +def add_md_link_path(path: str, lines: List[str]) -> List[str]: + ret = [] + for line in lines: + ret.append(re.sub(r"(\(#)", "(" + path + "#", line)) + return ret + + +def update_md_api(): + funcs = parse_functions(os.path.join(ROOT, "lua", "oil", "init.lua")) + lines = ["\n"] + render_md_api(funcs, 3) + ["\n"] + replace_section( + README, + r"^$", + r"^$", + lines, + ) + + +def update_readme_toc(): + toc = ["\n"] + generate_md_toc(README, max_level=1) + ["\n"] + replace_section( + README, + r"^$", + r"^$", + toc, + ) + + +def update_config_options(): + config_file = os.path.join(ROOT, "lua", "oil", "config.lua") + opt_lines = read_section(config_file, r"^\s*local default_config =", r"^}$") + replace_section( + README, + r"^require\(\"oil\"\)\.setup\(\{$", + r"^}\)$", + opt_lines, + ) + + +@dataclass +class ColumnDef: + name: str + adapters: str + editable: bool + summary: str + params: List["LuaParam"] = field(default_factory=list) + + +HL = [LuaParam("highlight", "string|fun", "Highlight group")] +TIME = [ + LuaParam("format", "string", "Format string (see :help strftime)"), +] +COL_DEFS = [ + ColumnDef( + "type", + "*", + False, + "The type of the entry (file, directory, link, etc)", + HL + [LuaParam("icons", "table", "Mapping of entry type to icon")], + ), + ColumnDef( + "icon", + "*", + False, + "An icon for the entry's type (requires nvim-web-devicons)", + HL + [], + ), + ColumnDef("size", "files, ssh", False, "The size of the file", HL + []), + ColumnDef("permissions", "files, ssh", True, "Access permissions of the file", HL + []), + ColumnDef("ctime", "files", False, "Change timestamp of the file", HL + TIME + []), + ColumnDef( + "mtime", "files", False, "Last modified time of the file", HL + TIME + [] + ), + ColumnDef("atime", "files", False, "Last access time of the file", HL + TIME + []), + ColumnDef( + "birthtime", "files", False, "The time the file was created", HL + TIME + [] + ), +] + + +def get_options_vimdoc() -> "VimdocSection": + section = VimdocSection("options", "oil-options") + config_file = os.path.join(ROOT, "lua", "oil", "config.lua") + opt_lines = read_section(config_file, r"^local default_config =", r"^}$") + lines = ["\n", ">\n", ' require("oil").setup({\n'] + lines.extend(indent(opt_lines, 4)) + lines.extend([" })\n", "<\n"]) + section.body = lines + return section + + +def get_highlights_vimdoc() -> "VimdocSection": + section = VimdocSection("Highlights", "oil-highlights", ["\n"]) + highlights = read_nvim_json('require("oil")._get_highlights()') + for hl in highlights: + name = hl["name"] + desc = hl.get("desc") + if desc is None: + continue + section.body.append(leftright(name, f"*hl-{name}*")) + section.body.extend(wrap(desc, 4)) + section.body.append("\n") + return section + + +def get_columns_vimdoc() -> "VimdocSection": + section = VimdocSection("Columns", "oil-columns", ["\n"]) + for col in COL_DEFS: + section.body.append(leftright(col.name, f"*column-{col.name}*")) + section.body.extend(wrap(f"Adapters: {col.adapters}", 4)) + if col.editable: + section.body.extend(wrap(f"Editable: this column is read/write", 4)) + section.body.extend(wrap(col.summary, 4)) + section.body.append("\n") + section.body.append(" Parameters:\n") + section.body.extend(format_vimdoc_params(col.params, 6)) + section.body.append("\n") + return section + + +def generate_vimdoc(): + doc = Vimdoc("oil.txt", "oil") + funcs = parse_functions(os.path.join(ROOT, "lua", "oil", "init.lua")) + doc.sections.extend( + [ + get_options_vimdoc(), + VimdocSection("API", "oil-api", render_vimdoc_api("oil", funcs)), + get_columns_vimdoc(), + get_highlights_vimdoc(), + ] + ) + + with open(VIMDOC, "w", encoding="utf-8") as ofile: + ofile.writelines(doc.render()) + + +def main() -> None: + """Update the README""" + update_config_options() + update_md_api() + update_readme_toc() + generate_vimdoc() diff --git a/.github/main.py b/.github/main.py new file mode 100755 index 0000000..4dffddf --- /dev/null +++ b/.github/main.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +import argparse +import os +import sys + +HERE = os.path.dirname(__file__) +ROOT = os.path.abspath(os.path.join(HERE, os.path.pardir)) +DOC = os.path.join(ROOT, "doc") + + +def main() -> None: + """Generate docs""" + sys.path.append(HERE) + parser = argparse.ArgumentParser(description=main.__doc__) + parser.add_argument("command", choices=["generate", "lint"]) + args = parser.parse_args() + if args.command == "generate": + import generate + + generate.main() + elif args.command == "lint": + from nvim_doc_tools import lint_md_links + + files = [os.path.join(ROOT, "README.md")] + [ + os.path.join(DOC, file) for file in os.listdir(DOC) if file.endswith(".md") + ] + lint_md_links.main(ROOT, files) + + +if __name__ == "__main__": + main() diff --git a/.github/nvim_doc_tools b/.github/nvim_doc_tools new file mode 160000 index 0000000..d146f2b --- /dev/null +++ b/.github/nvim_doc_tools @@ -0,0 +1 @@ +Subproject commit d146f2b7e72892b748e21d40a175267ce2ac1b7b diff --git a/.github/pre-commit b/.github/pre-commit new file mode 100755 index 0000000..49ee249 --- /dev/null +++ b/.github/pre-commit @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +luacheck lua tests + +stylua --check . diff --git a/.github/workflows/install_nvim.sh b/.github/workflows/install_nvim.sh new file mode 100644 index 0000000..c5119dc --- /dev/null +++ b/.github/workflows/install_nvim.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e +PLUGINS="$HOME/.local/share/nvim/site/pack/plugins/start" +mkdir -p "$PLUGINS" + +wget "https://github.com/neovim/neovim/releases/download/${NVIM_TAG}/nvim.appimage" +chmod +x nvim.appimage +./nvim.appimage --appimage-extract >/dev/null +rm -f nvim.appimage +mkdir -p ~/.local/share/nvim +mv squashfs-root ~/.local/share/nvim/appimage +sudo ln -s "$HOME/.local/share/nvim/appimage/AppRun" /usr/bin/nvim diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1c7b3f4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: Tests + +on: [push, pull_request] + +jobs: + luacheck: + name: Luacheck + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + + - name: Prepare + run: | + sudo apt-get update + sudo add-apt-repository universe + sudo apt install luarocks -y + sudo luarocks install luacheck + + - name: Run Luacheck + run: luacheck . + + stylua: + name: StyLua + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Stylua + uses: JohnnyMorganz/stylua-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.15.2 + args: --check . + + run_tests: + strategy: + matrix: + include: + - nvim_tag: v0.8.0 + - nvim_tag: v0.8.1 + + name: Run tests + runs-on: ubuntu-22.04 + env: + NVIM_TAG: ${{ matrix.nvim_tag }} + steps: + - uses: actions/checkout@v3 + + - name: Install Neovim and dependencies + run: | + bash ./.github/workflows/install_nvim.sh + + - name: Run tests + run: | + bash ./run_tests.sh diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml new file mode 100644 index 0000000..4b8e335 --- /dev/null +++ b/.github/workflows/update-docs.yml @@ -0,0 +1,36 @@ +name: Update docs + +on: push + +jobs: + update-readme: + name: Update docs + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + with: + submodules: true + + - name: Install Neovim and dependencies + env: + NVIM_TAG: v0.8.1 + run: | + bash ./.github/workflows/install_nvim.sh + + - name: Update docs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_MSG: | + [docgen] Update docs + skip-checks: true + run: | + git config user.email "actions@github" + git config user.name "Github Actions" + git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git + python -m pip install pyparsing==3.0.9 + python .github/main.py generate + python .github/main.py lint + nvim --headless -c 'set runtimepath+=.' -c 'helptags ALL' -c 'qall' + git add README.md doc + # Only commit and push if we have changes + git diff --quiet && git diff --staged --quiet || (git commit -m "${COMMIT_MSG}"; git push origin HEAD:${GITHUB_REF}) diff --git a/.gitignore b/.gitignore index 6fd0a37..7703aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ luac.out *.x86_64 *.hex +.direnv/ +.testenv/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c47e568 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".github/nvim_doc_tools"] + path = .github/nvim_doc_tools + url = https://github.com/stevearc/nvim_doc_tools diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..7efefde --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,19 @@ +max_comment_line_length = false +codes = true + +exclude_files = { + "tests/treesitter", +} + +ignore = { + "212", -- Unused argument + "631", -- Line is too long + "122", -- Setting a readonly global + "542", -- Empty if branch +} + +read_globals = { + "vim", + "a", + "assert", +} diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..3cfeffd --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,3 @@ +column_width = 100 +indent_type = "Spaces" +indent_width = 2 diff --git a/README.md b/README.md index b4e879d..d29b324 100644 --- a/README.md +++ b/README.md @@ -1 +1,333 @@ -# oil.nvim \ No newline at end of file +# oil.nvim + +A [vim-vinegar](https://github.com/tpope/vim-vinegar) like file explorer that lets you edit your filesystem like a normal Neovim buffer. + +https://user-images.githubusercontent.com/506791/209727111-6b4a11f4-634a-4efa-9461-80e9717cea94.mp4 + + + +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick start](#quick-start) +- [Options](#options) +- [Adapters](#adapters) +- [API](#api) +- [FAQ](#faq) + + + +## Requirements + +- Neovim 0.8+ +- (optional) [nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) for file icons + +## Installation + +oil.nvim supports all the usual plugin managers + +
+ Packer + +```lua +require('packer').startup(function() + use { + 'stevearc/oil.nvim', + config = function() require('oil').setup() end + } +end) +``` + +
+ +
+ Paq + +```lua +require "paq" { + {'stevearc/oil.nvim'}; +} +``` + +
+ +
+ vim-plug + +```vim +Plug 'stevearc/oil.nvim' +``` + +
+ +
+ dein + +```vim +call dein#add('stevearc/oil.nvim') +``` + +
+ +
+ Pathogen + +```sh +git clone --depth=1 https://github.com/stevearc/oil.nvim.git ~/.vim/bundle/ +``` + +
+ +
+ Neovim native package + +```sh +git clone --depth=1 https://github.com/stevearc/oil.nvim.git \ + "${XDG_DATA_HOME:-$HOME/.local/share}"/nvim/site/pack/oil/start/oil.nvim +``` + +
+ +## Quick start + +Add the following to your init.lua + +```lua +require("oil").setup() +``` + +Then open a directory with `nvim .`. Use `` to open a file/directory, and `-` to go up a directory. Otherwise, just treat it like a normal buffer and make changes as you like. Remember to `:w` when you're done to actually perform the actions. + +If you want to mimic the `vim-vinegar` method of navigating to the parent directory of a file, add this keymap: + +```lua +vim.keymap.set("n", "-", require("oil").open, { desc = "Open parent directory" }) +``` + +## Options + +```lua +require("oil").setup({ + -- Id is automatically added at the beginning, and name at the end + -- See :help oil-columns + columns = { + "icon", + -- "permissions", + -- "size", + -- "mtime", + }, + -- Window-local options to use for oil buffers + win_options = { + wrap = false, + signcolumn = "no", + cursorcolumn = false, + foldcolumn = "0", + spell = false, + list = false, + conceallevel = 3, + concealcursor = "n", + }, + -- Restore window options to previous values when leaving an oil buffer + restore_win_options = true, + -- Skip the confirmation popup for simple operations + skip_confirm_for_simple_edits = false, + -- Keymaps in oil buffer. Can be any value that `vim.keymap.set` accepts OR a table of keymap + -- options with a `callback` (e.g. { callback = function() ... end, desc = "", nowait = true }) + -- Additionally, if it is a string that matches "action.", + -- it will use the mapping at require("oil.action"). + -- Set to `false` to remove a keymap + keymaps = { + ["g?"] = "actions.show_help", + [""] = "actions.select", + [""] = "actions.select_vsplit", + [""] = "actions.select_split", + [""] = "actions.preview", + [""] = "actions.close", + ["-"] = "actions.parent", + ["_"] = "actions.open_cwd", + ["`"] = "actions.cd", + ["~"] = "actions.tcd", + ["g."] = "actions.toggle_hidden", + }, + view_options = { + -- Show files and directories that start with "." + show_hidden = false, + }, + -- Configuration for the floating window in oil.open_float + float = { + -- Padding around the floating window + padding = 2, + max_width = 0, + max_height = 0, + border = "rounded", + win_options = { + winblend = 10, + }, + }, + adapters = { + ["oil://"] = "files", + ["oil-ssh://"] = "ssh", + }, + -- When opening the parent of a file, substitute these url schemes + remap_schemes = { + ["scp://"] = "oil-ssh://", + ["sftp://"] = "oil-ssh://", + }, +}) +``` + +## Adapters + +Oil does all of its filesystem interaction through an _adapter_ abstraction. In practice, this means that oil can be used to view and modify files in more places than just the local filesystem, so long as the destination has an adapter implementation. + +Note that file operations work _across adapters_. This means that you can use oil to copy files to/from a remote server using the ssh adapter just as easily as you can copy files from one directory to another on your local machine. + +### SSH + +This adapter allows you to browse files over ssh, much like netrw. To use it, simply open a buffer using the following name template: + +``` +nvim oil-ssh://[username@]hostname[:port]/[path] +``` + +This should look familiar. In fact, if you replace `oil-ssh://` with `sftp://`, this is the exact same url format that netrw uses. + +While this adapter effectively replaces netrw for directory browsing, it still relies on netrw for file editing. When you open a file from oil, it will use the `scp://host/path/to/file.txt` format that triggers remote editing via netrw. + + +## API + + + +### get_entry_on_line(bufnr, lnum) + +`get_entry_on_line(bufnr, lnum): nil|oil.Entry` \ +Get the entry on a specific line (1-indexed) + +| Param | Type | Desc | +| ----- | --------- | ---- | +| bufnr | `integer` | | +| lnum | `integer` | | + +### get_cursor_entry() + +`get_cursor_entry(): nil|oil.Entry` \ +Get the entry currently under the cursor + + +### discard_all_changes() + +`discard_all_changes()` \ +Discard all changes made to oil buffers + + +### set_columns(cols) + +`set_columns(cols)` \ +Change the display columns for oil + +| Param | Type | Desc | +| ----- | ------------------ | ---- | +| cols | `oil.ColumnSpec[]` | | + +### get_current_dir() + +`get_current_dir(): nil|string` \ +Get the current directory + + +### open_float(dir) + +`open_float(dir)` \ +Open oil browser in a floating window + +| Param | Type | Desc | +| ----- | ------------- | ----------------------------------------------------------- | +| dir | `nil\|string` | When nil, open the parent of the current buffer, or the cwd | + +### open(dir) + +`open(dir)` \ +Open oil browser for a directory + +| Param | Type | Desc | +| ----- | ------------- | ----------------------------------------------------------- | +| dir | `nil\|string` | When nil, open the parent of the current buffer, or the cwd | + +### close() + +`close()` \ +Restore the buffer that was present when oil was opened + + +### select(opts) + +`select(opts)` \ +Select the entry under the cursor + +| Param | Type | Desc | | +| ----- | ---------- | -------------------------------------------------- | ------------------------------------- | +| opts | `table` | | | +| | vertical | `boolean` | Open the buffer in a vertical split | +| | horizontal | `boolean` | Open the buffer in a horizontal split | +| | split | `"aboveleft"\|"belowright"\|"topleft"\|"botright"` | Split modifier | +| | preview | `boolean` | Open the buffer in a preview window | + +### save(opts) + +`save(opts)` \ +Save all changes + +| Param | Type | Desc | | +| ----- | ------------ | -------------- | ------------------------------------------------------------------------------------------- | +| opts | `nil\|table` | | | +| | confirm | `nil\|boolean` | Show confirmation when true, never when false, respect skip_confirm_for_simple_edits if nil | + +### setup(opts) + +`setup(opts)` \ +Initialize oil + +| Param | Type | Desc | +| ----- | ------------ | ---- | +| opts | `nil\|table` | | + + + + +## FAQ + +**Q: Why "oil"**? + +**A:** From the [vim-vinegar](https://github.com/tpope/vim-vinegar) README, a quote by Drew Neil: + +> Split windows and the project drawer go together like oil and vinegar + +Vinegar was taken. Let's be oil. +Plus, I think it's pretty slick ;) + +**Q: Why would I want to use oil vs any other plugin?** + +**A:** + +- You like to use a netrw-like view to browse directories (as opposed to a file tree) +- AND you want to be able to edit your filesystem like a buffer +- AND you want to perform cross-directory actions. AFAIK there is no other plugin that does this. + +If you don't need those features specifically, check out the alternatives listed below + +**Q: Why write another plugin yourself instead of adding functionality to one that already exists**? + +**A:** Because I am a _maniac control freak_. + +**Q: What are some alternatives?** + +**A:** + +- [vim-vinegar](https://github.com/tpope/vim-vinegar): The granddaddy. This made me fall in love with single-directory file browsing. I stopped using it when I encountered netrw bugs and performance issues. +- [defx.nvim](https://github.com/Shougo/defx.nvim): What I switched to after vim-vinegar. Much more flexible and performant, but requires python and the API is a little hard to work with. +- [dirbuf.nvim](https://github.com/elihunter173/dirbuf.nvim): The first plugin I encountered that let you edit the filesystem like a buffer. Never used it because it [can't do cross-directory edits](https://github.com/elihunter173/dirbuf.nvim/issues/7). +- [lir.nvim](https://github.com/tamago324/lir.nvim): What I used prior to writing this plugin. Similar to vim-vinegar, but with better Neovim integration (floating windows, lua API). +- [vim-dirvish](https://github.com/justinmk/vim-dirvish): Never personally used, but well-established, stable, simple directory browser. +- [vidir](https://github.com/trapd00r/vidir): Never personally used, but might be the first plugin to come up with the idea of editing a directory like a buffer. + +There's also file trees like [neo-tree](https://github.com/nvim-neo-tree/neo-tree.nvim) and [nvim-tree](https://github.com/nvim-tree/nvim-tree.lua), but they're really a different category entirely. diff --git a/doc/oil.txt b/doc/oil.txt new file mode 100644 index 0000000..09c663f --- /dev/null +++ b/doc/oil.txt @@ -0,0 +1,251 @@ +*oil.txt* +*Oil* *oil* *oil.nvim* +-------------------------------------------------------------------------------- +CONTENTS *oil-contents* + + 1. Options.....................................................|oil-options| + 2. Api.............................................................|oil-api| + 3. Columns.....................................................|oil-columns| + 4. Highlights...............................................|oil-highlights| + +-------------------------------------------------------------------------------- +OPTIONS *oil-options* + +> + require("oil").setup({ + -- Id is automatically added at the beginning, and name at the end + -- See :help oil-columns + columns = { + "icon", + -- "permissions", + -- "size", + -- "mtime", + }, + -- Window-local options to use for oil buffers + win_options = { + wrap = false, + signcolumn = "no", + cursorcolumn = false, + foldcolumn = "0", + spell = false, + list = false, + conceallevel = 3, + concealcursor = "n", + }, + -- Restore window options to previous values when leaving an oil buffer + restore_win_options = true, + -- Skip the confirmation popup for simple operations + skip_confirm_for_simple_edits = false, + -- Keymaps in oil buffer. Can be any value that `vim.keymap.set` accepts OR a table of keymap + -- options with a `callback` (e.g. { callback = function() ... end, desc = "", nowait = true }) + -- Additionally, if it is a string that matches "action.", + -- it will use the mapping at require("oil.action"). + -- Set to `false` to remove a keymap + keymaps = { + ["g?"] = "actions.show_help", + [""] = "actions.select", + [""] = "actions.select_vsplit", + [""] = "actions.select_split", + [""] = "actions.preview", + [""] = "actions.close", + ["-"] = "actions.parent", + ["_"] = "actions.open_cwd", + ["`"] = "actions.cd", + ["~"] = "actions.tcd", + ["g."] = "actions.toggle_hidden", + }, + view_options = { + -- Show files and directories that start with "." + show_hidden = false, + }, + -- Configuration for the floating window in oil.open_float + float = { + -- Padding around the floating window + padding = 2, + max_width = 0, + max_height = 0, + border = "rounded", + win_options = { + winblend = 10, + }, + }, + adapters = { + ["oil://"] = "files", + ["oil-ssh://"] = "ssh", + }, + -- When opening the parent of a file, substitute these url schemes + remap_schemes = { + ["scp://"] = "oil-ssh://", + ["sftp://"] = "oil-ssh://", + }, + }) +< + +-------------------------------------------------------------------------------- +API *oil-api* + +get_entry_on_line({bufnr}, {lnum}): nil|oil.Entry *oil.get_entry_on_line* + Get the entry on a specific line (1-indexed) + + Parameters: + {bufnr} `integer` + {lnum} `integer` + +get_cursor_entry(): nil|oil.Entry *oil.get_cursor_entry* + Get the entry currently under the cursor + + +discard_all_changes() *oil.discard_all_changes* + Discard all changes made to oil buffers + + +set_columns({cols}) *oil.set_columns* + Change the display columns for oil + + Parameters: + {cols} `oil.ColumnSpec[]` + +get_current_dir(): nil|string *oil.get_current_dir* + Get the current directory + + +open_float({dir}) *oil.open_float* + Open oil browser in a floating window + + Parameters: + {dir} `nil|string` When nil, open the parent of the current buffer, or the + cwd + +open({dir}) *oil.open* + Open oil browser for a directory + + Parameters: + {dir} `nil|string` When nil, open the parent of the current buffer, or the + cwd + +close() *oil.close* + Restore the buffer that was present when oil was opened + + +select({opts}) *oil.select* + Select the entry under the cursor + + Parameters: + {opts} `table` + {vertical} `boolean` Open the buffer in a vertical split + {horizontal} `boolean` Open the buffer in a horizontal split + {split} `"aboveleft"|"belowright"|"topleft"|"botright"` Split + modifier + {preview} `boolean` Open the buffer in a preview window + +save({opts}) *oil.save* + Save all changes + + Parameters: + {opts} `nil|table` + {confirm} `nil|boolean` Show confirmation when true, never when false, + respect skip_confirm_for_simple_edits if nil + +setup({opts}) *oil.setup* + Initialize oil + + Parameters: + {opts} `nil|table` + +-------------------------------------------------------------------------------- +COLUMNS *oil-columns* + +type *column-type* + Adapters: * + The type of the entry (file, directory, link, etc) + + Parameters: + {highlight} `string|fun` Highlight group + {icons} `table` Mapping of entry type to icon + +icon *column-icon* + Adapters: * + An icon for the entry's type (requires nvim-web-devicons) + + Parameters: + {highlight} `string|fun` Highlight group + +size *column-size* + Adapters: files, ssh + The size of the file + + Parameters: + {highlight} `string|fun` Highlight group + +permissions *column-permissions* + Adapters: files, ssh + Editable: this column is read/write + Access permissions of the file + + Parameters: + {highlight} `string|fun` Highlight group + +ctime *column-ctime* + Adapters: files + Change timestamp of the file + + Parameters: + {highlight} `string|fun` Highlight group + {format} `string` Format string (see :help strftime) + +mtime *column-mtime* + Adapters: files + Last modified time of the file + + Parameters: + {highlight} `string|fun` Highlight group + {format} `string` Format string (see :help strftime) + +atime *column-atime* + Adapters: files + Last access time of the file + + Parameters: + {highlight} `string|fun` Highlight group + {format} `string` Format string (see :help strftime) + +birthtime *column-birthtime* + Adapters: files + The time the file was created + + Parameters: + {highlight} `string|fun` Highlight group + {format} `string` Format string (see :help strftime) + +-------------------------------------------------------------------------------- +HIGHLIGHTS *oil-highlights* + +OilDir *hl-OilDir* + Directories in an oil buffer + +OilSocket *hl-OilSocket* + Socket files in an oil buffer + +OilLink *hl-OilLink* + Soft links in an oil buffer + +OilFile *hl-OilFile* + Normal files in an oil buffer + +OilCreate *hl-OilCreate* + Create action in the oil preview window + +OilDelete *hl-OilDelete* + Delete action in the oil preview window + +OilMove *hl-OilMove* + Move action in the oil preview window + +OilCopy *hl-OilCopy* + Copy action in the oil preview window + +OilChange *hl-OilChange* + Change action in the oil preview window + +================================================================================ +vim:tw=80:ts=2:ft=help:norl:syntax=help: diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..1c8e562 --- /dev/null +++ b/doc/tags @@ -0,0 +1,37 @@ +Oil oil.txt /*Oil* +column-atime oil.txt /*column-atime* +column-birthtime oil.txt /*column-birthtime* +column-ctime oil.txt /*column-ctime* +column-icon oil.txt /*column-icon* +column-mtime oil.txt /*column-mtime* +column-permissions oil.txt /*column-permissions* +column-size oil.txt /*column-size* +column-type oil.txt /*column-type* +hl-OilChange oil.txt /*hl-OilChange* +hl-OilCopy oil.txt /*hl-OilCopy* +hl-OilCreate oil.txt /*hl-OilCreate* +hl-OilDelete oil.txt /*hl-OilDelete* +hl-OilDir oil.txt /*hl-OilDir* +hl-OilFile oil.txt /*hl-OilFile* +hl-OilLink oil.txt /*hl-OilLink* +hl-OilMove oil.txt /*hl-OilMove* +hl-OilSocket oil.txt /*hl-OilSocket* +oil oil.txt /*oil* +oil-api oil.txt /*oil-api* +oil-columns oil.txt /*oil-columns* +oil-contents oil.txt /*oil-contents* +oil-highlights oil.txt /*oil-highlights* +oil-options oil.txt /*oil-options* +oil.close oil.txt /*oil.close* +oil.discard_all_changes oil.txt /*oil.discard_all_changes* +oil.get_current_dir oil.txt /*oil.get_current_dir* +oil.get_cursor_entry oil.txt /*oil.get_cursor_entry* +oil.get_entry_on_line oil.txt /*oil.get_entry_on_line* +oil.nvim oil.txt /*oil.nvim* +oil.open oil.txt /*oil.open* +oil.open_float oil.txt /*oil.open_float* +oil.save oil.txt /*oil.save* +oil.select oil.txt /*oil.select* +oil.set_columns oil.txt /*oil.set_columns* +oil.setup oil.txt /*oil.setup* +oil.txt oil.txt /*oil.txt* diff --git a/lua/oil/actions.lua b/lua/oil/actions.lua new file mode 100644 index 0000000..6950dea --- /dev/null +++ b/lua/oil/actions.lua @@ -0,0 +1,87 @@ +local oil = require("oil") + +local M = {} + +M.show_help = { + desc = "Show default keymaps", + callback = function() + local config = require("oil.config") + require("oil.keymap_util").show_help(config.keymaps) + end, +} + +M.select = { + desc = "Open the entry under the cursor", + callback = oil.select, +} + +M.select_vsplit = { + desc = "Open the entry under the cursor in a vertical split", + callback = function() + oil.select({ vertical = true }) + end, +} + +M.select_split = { + desc = "Open the entry under the cursor in a horizontal split", + callback = function() + oil.select({ horizontal = true }) + end, +} + +M.preview = { + desc = "Open the entry under the cursor in a preview window", + callback = function() + oil.select({ preview = true }) + end, +} + +M.parent = { + desc = "Navigate to the parent path", + callback = oil.open, +} + +M.close = { + desc = "Close oil and restore original buffer", + callback = oil.close, +} + +---@param cmd string +local function cd(cmd) + local dir = oil.get_current_dir() + if dir then + vim.cmd({ cmd = cmd, args = { dir } }) + else + vim.notify("Cannot :cd; not in a directory", vim.log.levels.WARN) + end +end + +M.cd = { + desc = ":cd to the current oil directory", + callback = function() + cd("cd") + end, +} + +M.tcd = { + desc = ":tcd to the current oil directory", + callback = function() + cd("tcd") + end, +} + +M.open_cwd = { + desc = "Open oil in Neovim's cwd", + callback = function() + oil.open(vim.fn.getcwd()) + end, +} + +M.toggle_hidden = { + desc = "Toggle hidden files and directories", + callback = function() + require("oil.view").toggle_hidden() + end, +} + +return M diff --git a/lua/oil/adapters/files.lua b/lua/oil/adapters/files.lua new file mode 100644 index 0000000..91a78fa --- /dev/null +++ b/lua/oil/adapters/files.lua @@ -0,0 +1,406 @@ +local cache = require("oil.cache") +local columns = require("oil.columns") +local config = require("oil.config") +local fs = require("oil.fs") +local permissions = require("oil.adapters.files.permissions") +local util = require("oil.util") +local FIELD = require("oil.constants").FIELD +local M = {} + +local function read_link_data(path, cb) + vim.loop.fs_readlink( + path, + vim.schedule_wrap(function(link_err, link) + if link_err then + cb(link_err) + else + local stat_path = link + if not fs.is_absolute(link) then + stat_path = fs.join(vim.fn.fnamemodify(path, ":h"), link) + end + vim.loop.fs_stat(stat_path, function(stat_err, stat) + cb(nil, link, stat) + end) + end + end) + ) +end + +---@param path string +---@param entry_type nil|oil.EntryType +---@return string +M.to_short_os_path = function(path, entry_type) + local shortpath = fs.shorten_path(fs.posix_to_os_path(path)) + if entry_type == "directory" then + shortpath = util.addslash(shortpath) + end + return shortpath +end + +local file_columns = {} + +local fs_stat_meta_fields = { + stat = function(parent_url, entry, cb) + local _, path = util.parse_url(parent_url) + local dir = fs.posix_to_os_path(path) + vim.loop.fs_stat(fs.join(dir, entry[FIELD.name]), cb) + end, +} + +file_columns.size = { + meta_fields = fs_stat_meta_fields, + + render = function(entry, conf) + local meta = entry[FIELD.meta] + local stat = meta.stat + if not stat then + return "" + end + if stat.size >= 1e9 then + return string.format("%.1fG", stat.size / 1e9) + elseif stat.size >= 1e6 then + return string.format("%.1fM", stat.size / 1e6) + elseif stat.size >= 1e3 then + return string.format("%.1fk", stat.size / 1e3) + else + return string.format("%d", stat.size) + end + end, + + parse = function(line, conf) + return line:match("^(%d+%S*)%s+(.*)$") + end, +} + +-- TODO support file permissions on windows +if not fs.is_windows then + file_columns.permissions = { + meta_fields = fs_stat_meta_fields, + + render = function(entry, conf) + local meta = entry[FIELD.meta] + local stat = meta.stat + if not stat then + return "" + end + return permissions.mode_to_str(stat.mode) + end, + + parse = function(line, conf) + return permissions.parse(line) + end, + + compare = function(entry, parsed_value) + local meta = entry[FIELD.meta] + if parsed_value and meta.stat and meta.stat.mode then + local mask = bit.lshift(1, 12) - 1 + local old_mode = bit.band(meta.stat.mode, mask) + if parsed_value ~= old_mode then + return true + end + end + return false + end, + + render_action = function(action) + local _, path = util.parse_url(action.url) + return string.format( + "CHMOD %s %s", + permissions.mode_to_octal_str(action.value), + M.to_short_os_path(path, action.entry_type) + ) + end, + + perform_action = function(action, callback) + local _, path = util.parse_url(action.url) + path = fs.posix_to_os_path(path) + vim.loop.fs_stat(path, function(err, stat) + if err then + return callback(err) + end + -- We are only changing the lower 12 bits of the mode + local mask = bit.bnot(bit.lshift(1, 12) - 1) + local old_mode = bit.band(stat.mode, mask) + vim.loop.fs_chmod(path, bit.bor(old_mode, action.value), callback) + end) + end, + } +end + +local current_year = vim.fn.strftime("%Y") + +for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do + file_columns[time_key] = { + meta_fields = fs_stat_meta_fields, + + render = function(entry, conf) + local meta = entry[FIELD.meta] + local stat = meta.stat + local fmt = conf and conf.format + local ret + if fmt then + ret = vim.fn.strftime(fmt, stat[time_key].sec) + else + local year = vim.fn.strftime("%Y", stat[time_key].sec) + if year ~= current_year then + ret = vim.fn.strftime("%b %d %Y", stat[time_key].sec) + else + ret = vim.fn.strftime("%b %d %H:%M", stat[time_key].sec) + end + end + return ret + end, + + parse = function(line, conf) + local fmt = conf and conf.format + local pattern + if fmt then + pattern = fmt:gsub("%%.", "%%S+") + else + pattern = "%S+%s+%d+%s+%d%d:?%d%d" + end + return line:match("^(" .. pattern .. ")%s+(.+)$") + end, + } +end + +---@param name string +---@return nil|oil.ColumnDefinition +M.get_column = function(name) + return file_columns[name] +end + +---@param url string +---@param callback fun(url: string) +M.normalize_url = function(url, callback) + local scheme, path = util.parse_url(url) + local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ":p") + local realpath = vim.loop.fs_realpath(os_path) or os_path + local norm_path = util.addslash(fs.os_to_posix_path(realpath)) + if norm_path ~= os_path then + callback(scheme .. fs.os_to_posix_path(norm_path)) + else + callback(util.addslash(url)) + end +end + +---@param url string +---@param column_defs string[] +---@param callback fun(err: nil|string, entries: nil|oil.InternalEntry[]) +M.list = function(url, column_defs, callback) + local _, path = util.parse_url(url) + local dir = fs.posix_to_os_path(path) + local fetch_meta = columns.get_metadata_fetcher(M, column_defs) + cache.begin_update_url(url) + local function cb(err, data) + if err or not data then + cache.end_update_url(url) + end + callback(err, data) + end + vim.loop.fs_opendir(dir, function(open_err, fd) + if open_err then + if open_err:match("^ENOENT: no such file or directory") then + -- If the directory doesn't exist, treat the list as a success. We will be able to traverse + -- and edit a not-yet-existing directory. + return cb() + else + return cb(open_err) + end + end + local read_next + read_next = function(read_err) + if read_err then + cb(read_err) + return + end + vim.loop.fs_readdir(fd, function(err, entries) + if err then + vim.loop.fs_closedir(fd, function() + cb(err) + end) + return + elseif entries then + local poll = util.cb_collect(#entries, function(inner_err) + if inner_err then + cb(inner_err) + else + cb(nil, true) + read_next() + end + end) + for _, entry in ipairs(entries) do + local cache_entry = cache.create_entry(url, entry.name, entry.type) + fetch_meta(url, cache_entry, function(meta_err) + if err then + poll(meta_err) + else + local meta = cache_entry[FIELD.meta] + -- Make sure we always get fs_stat info for links + if entry.type == "link" then + read_link_data(fs.join(dir, entry.name), function(link_err, link, link_stat) + if link_err then + poll(link_err) + else + if not meta then + meta = {} + cache_entry[FIELD.meta] = meta + end + meta.link = link + meta.link_stat = link_stat + cache.store_entry(url, cache_entry) + poll() + end + end) + else + cache.store_entry(url, cache_entry) + poll() + end + end + end) + end + else + vim.loop.fs_closedir(fd, function(close_err) + if close_err then + cb(close_err) + else + cb() + end + end) + end + end) + end + read_next() + end, 100) -- TODO do some testing for this +end + +---@param bufnr integer +---@return boolean +M.is_modifiable = function(bufnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local _, path = util.parse_url(bufname) + local dir = fs.posix_to_os_path(path) + local stat = vim.loop.fs_stat(dir) + if not stat then + return true + end + + -- Can't do permissions checks on windows + if fs.is_windows then + return true + end + + local uid = vim.loop.getuid() + local gid = vim.loop.getgid() + local rwx + if uid == stat.uid then + rwx = bit.rshift(stat.mode, 6) + elseif gid == stat.gid then + rwx = bit.rshift(stat.mode, 3) + else + rwx = stat.mode + end + return bit.band(rwx, 2) ~= 0 +end + +---@param url string +M.url_to_buffer_name = function(url) + local _, path = util.parse_url(url) + return fs.posix_to_os_path(path) +end + +---@param action oil.Action +---@return string +M.render_action = function(action) + if action.type == "create" then + local _, path = util.parse_url(action.url) + local ret = string.format("CREATE %s", M.to_short_os_path(path, action.entry_type)) + if action.link then + ret = ret .. " -> " .. fs.posix_to_os_path(action.link) + end + return ret + elseif action.type == "delete" then + local _, path = util.parse_url(action.url) + return string.format("DELETE %s", M.to_short_os_path(path, action.entry_type)) + elseif action.type == "move" or action.type == "copy" then + local dest_adapter = config.get_adapter_by_scheme(action.dest_url) + if dest_adapter == M then + local _, src_path = util.parse_url(action.src_url) + local _, dest_path = util.parse_url(action.dest_url) + return string.format( + " %s %s -> %s", + action.type:upper(), + M.to_short_os_path(src_path, action.entry_type), + M.to_short_os_path(dest_path, action.entry_type) + ) + else + -- We should never hit this because we don't implement supports_xfer + error("files adapter doesn't support cross-adapter move/copy") + end + else + error(string.format("Bad action type: '%s'", action.type)) + end +end + +---@param action oil.Action +---@param cb fun(err: nil|string) +M.perform_action = function(action, cb) + if action.type == "create" then + local _, path = util.parse_url(action.url) + path = fs.posix_to_os_path(path) + if action.entry_type == "directory" then + vim.loop.fs_mkdir(path, 493, function(err) + -- Ignore if the directory already exists + if not err or err:match("^EEXIST:") then + cb() + else + cb(err) + end + end) -- 0755 + elseif action.entry_type == "link" and action.link then + local flags = nil + local target = fs.posix_to_os_path(action.link) + if fs.is_windows then + flags = { + dir = vim.fn.isdirectory(target) == 1, + junction = false, + } + end + vim.loop.fs_symlink(target, path, flags, cb) + else + fs.touch(path, cb) + end + elseif action.type == "delete" then + local _, path = util.parse_url(action.url) + path = fs.posix_to_os_path(path) + fs.recursive_delete(action.entry_type, path, cb) + elseif action.type == "move" then + local dest_adapter = config.get_adapter_by_scheme(action.dest_url) + if dest_adapter == M then + local _, src_path = util.parse_url(action.src_url) + local _, dest_path = util.parse_url(action.dest_url) + src_path = fs.posix_to_os_path(src_path) + dest_path = fs.posix_to_os_path(dest_path) + fs.recursive_move(action.entry_type, src_path, dest_path, vim.schedule_wrap(cb)) + else + -- We should never hit this because we don't implement supports_xfer + cb("files adapter doesn't support cross-adapter move") + end + elseif action.type == "copy" then + local dest_adapter = config.get_adapter_by_scheme(action.dest_url) + if dest_adapter == M then + local _, src_path = util.parse_url(action.src_url) + local _, dest_path = util.parse_url(action.dest_url) + src_path = fs.posix_to_os_path(src_path) + dest_path = fs.posix_to_os_path(dest_path) + fs.recursive_copy(action.entry_type, src_path, dest_path, cb) + else + -- We should never hit this because we don't implement supports_xfer + cb("files adapter doesn't support cross-adapter copy") + end + else + cb(string.format("Bad action type: %s", action.type)) + end +end + +return M diff --git a/lua/oil/adapters/files/permissions.lua b/lua/oil/adapters/files/permissions.lua new file mode 100644 index 0000000..fcd9984 --- /dev/null +++ b/lua/oil/adapters/files/permissions.lua @@ -0,0 +1,103 @@ +local M = {} + +---@param exe_modifier nil|string +---@param num integer +---@return string +local function perm_to_str(exe_modifier, num) + local str = (bit.band(num, 4) ~= 0 and "r" or "-") .. (bit.band(num, 2) ~= 0 and "w" or "-") + if exe_modifier then + if bit.band(num, 1) ~= 0 then + return str .. exe_modifier + else + return str .. exe_modifier:upper() + end + else + return str .. (bit.band(num, 1) ~= 0 and "x" or "-") + end +end + +---@param mode integer +---@return string +M.mode_to_str = function(mode) + local extra = bit.rshift(mode, 9) + return perm_to_str(bit.band(extra, 4) ~= 0 and "s", bit.rshift(mode, 6)) + .. perm_to_str(bit.band(extra, 2) ~= 0 and "s", bit.rshift(mode, 3)) + .. perm_to_str(bit.band(extra, 1) ~= 0 and "t", mode) +end + +---@param mode integer +---@return string +M.mode_to_octal_str = function(mode) + local mask = 7 + return tostring(bit.band(mask, bit.rshift(mode, 9))) + .. tostring(bit.band(mask, bit.rshift(mode, 6))) + .. tostring(bit.band(mask, bit.rshift(mode, 3))) + .. tostring(bit.band(mask, mode)) +end + +---@param str string String of 3 characters +---@return integer +local function str_to_mode(str) + local r, w, x = unpack(vim.split(str, "")) + local mode = 0 + if r == "r" then + mode = bit.bor(mode, 4) + elseif r ~= "-" then + return nil + end + if w == "w" then + mode = bit.bor(mode, 2) + elseif w ~= "-" then + return nil + end + -- t means sticky and executable + -- T means sticky, not executable + -- s means setuid/setgid and executable + -- S means setuid/setgid and not executable + if x == "x" or x == "t" or x == "s" then + mode = bit.bor(mode, 1) + elseif x ~= "-" and x ~= "T" and x ~= "S" then + return nil + end + return mode +end + +---@param perm string +---@return integer +local function parse_extra_bits(perm) + perm = perm:lower() + local mode = 0 + if perm:sub(3, 3) == "s" then + mode = bit.bor(mode, 4) + end + if perm:sub(6, 6) == "s" then + mode = bit.bor(mode, 2) + end + if perm:sub(9, 9) == "t" then + mode = bit.bor(mode, 1) + end + return mode +end + +---@param line string +---@return nil|integer +---@return nil|string +M.parse = function(line) + local strval, rem = line:match("^([r%-][w%-][xsS%-][r%-][w%-][xsS%-][r%-][w%-][xtT%-])%s*(.*)$") + if not strval then + return + end + local user_mode = str_to_mode(strval:sub(1, 3)) + local group_mode = str_to_mode(strval:sub(4, 6)) + local any_mode = str_to_mode(strval:sub(7, 9)) + local extra = parse_extra_bits(strval) + if not user_mode or not group_mode or not any_mode then + return + end + local mode = bit.bor(bit.lshift(user_mode, 6), bit.lshift(group_mode, 3)) + mode = bit.bor(mode, any_mode) + mode = bit.bor(mode, bit.lshift(extra, 9)) + return mode, rem +end + +return M diff --git a/lua/oil/adapters/ssh.lua b/lua/oil/adapters/ssh.lua new file mode 100644 index 0000000..9b5eeb5 --- /dev/null +++ b/lua/oil/adapters/ssh.lua @@ -0,0 +1,454 @@ +local cache = require("oil.cache") +local config = require("oil.config") +local fs = require("oil.fs") +local files = require("oil.adapters.files") +local permissions = require("oil.adapters.files.permissions") +local ssh_connection = require("oil.adapters.ssh.connection") +local pathutil = require("oil.pathutil") +local shell = require("oil.shell") +local util = require("oil.util") +local FIELD = require("oil.constants").FIELD +local M = {} + +---@class oil.sshUrl +---@field scheme string +---@field host string +---@field user nil|string +---@field port nil|integer +---@field path string + +---@param oil_url string +---@return oil.sshUrl +local function parse_url(oil_url) + local scheme, url = util.parse_url(oil_url) + local ret = { scheme = scheme } + local username, rem = url:match("^([^@%s]+)@(.*)$") + ret.user = username + url = rem or url + local host, port, path = url:match("^([^:]+):(%d+)/(.*)$") + if host then + ret.host = host + ret.port = tonumber(port) + ret.path = path + else + host, path = url:match("^([^/]+)/(.*)$") + ret.host = host + ret.path = path + end + if not ret.host or not ret.path then + error(string.format("Malformed SSH url: %s", oil_url)) + end + + return ret +end + +---@param url oil.sshUrl +---@return string +local function url_to_str(url) + local pieces = { url.scheme } + if url.user then + table.insert(pieces, url.user) + table.insert(pieces, "@") + end + table.insert(pieces, url.host) + if url.port then + table.insert(pieces, string.format(":%d", url.port)) + end + table.insert(pieces, "/") + table.insert(pieces, url.path) + return table.concat(pieces, "") +end + +---@param url oil.sshUrl +---@return string +local function url_to_scp(url) + local pieces = {} + if url.user then + table.insert(pieces, url.user) + table.insert(pieces, "@") + end + table.insert(pieces, url.host) + table.insert(pieces, ":") + if url.port then + table.insert(pieces, string.format(":%d", url.port)) + end + table.insert(pieces, url.path) + return table.concat(pieces, "") +end + +local _connections = {} +---@param url string +---@param allow_retry nil|boolean +local function get_connection(url, allow_retry) + local res = parse_url(url) + res.scheme = config.adapters.ssh + res.path = "" + local key = url_to_str(res) + local conn = _connections[key] + if not conn or (allow_retry and conn.connection_error) then + conn = ssh_connection.new(res) + _connections[key] = conn + end + return conn +end + +local typechar_map = { + l = "link", + d = "directory", + p = "fifo", + s = "socket", + ["-"] = "file", +} +---@param line string +---@return string Name of entry +---@return oil.EntryType +---@return nil|table Metadata for entry +local function parse_ls_line(line) + local typechar, perms, refcount, user, group, size, date, name = + line:match("^(.)(%S+)%s+(%d+)%s+(%S+)%s+(%S+)%s+(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)$") + if not typechar then + error(string.format("Could not parse '%s'", line)) + end + local type = typechar_map[typechar] or "file" + + local meta = { + user = user, + group = group, + mode = permissions.parse(perms), + refcount = tonumber(refcount), + size = tonumber(size), + iso_modified_date = date, + } + if type == "link" then + local link + name, link = unpack(vim.split(name, " -> ", { plain = true })) + if vim.endswith(link, "/") then + link = link:sub(1, #link - 1) + end + meta.link = link + end + + return name, type, meta +end + +local ssh_columns = {} +ssh_columns.permissions = { + render = function(entry, conf) + local meta = entry[FIELD.meta] + return permissions.mode_to_str(meta.mode) + end, + + parse = function(line, conf) + return permissions.parse(line) + end, + + compare = function(entry, parsed_value) + local meta = entry[FIELD.meta] + if parsed_value and meta.mode then + local mask = bit.lshift(1, 12) - 1 + local old_mode = bit.band(meta.mode, mask) + if parsed_value ~= old_mode then + return true + end + end + return false + end, + + render_action = function(action) + return string.format("CHMOD %s %s", permissions.mode_to_octal_str(action.value), action.url) + end, + + perform_action = function(action, callback) + local res = parse_url(action.url) + local conn = get_connection(action.url) + local octal = permissions.mode_to_octal_str(action.value) + conn:run(string.format("chmod %s '%s'", octal, res.path), callback) + end, +} + +ssh_columns.size = { + render = function(entry, conf) + local meta = entry[FIELD.meta] + if meta.size >= 1e9 then + return string.format("%.1fG", meta.size / 1e9) + elseif meta.size >= 1e6 then + return string.format("%.1fM", meta.size / 1e6) + elseif meta.size >= 1e3 then + return string.format("%.1fk", meta.size / 1e3) + else + return string.format("%d", meta.size) + end + end, + + parse = function(line, conf) + return line:match("^(%d+%S*)%s+(.*)$") + end, +} + +---@param name string +---@return nil|oil.ColumnDefinition +M.get_column = function(name) + return ssh_columns[name] +end + +---For debugging +M.open_terminal = function() + local conn = get_connection(vim.api.nvim_buf_get_name(0)) + if conn then + conn:open_terminal() + end +end + +---@param bufname string +---@return string +M.get_parent = function(bufname) + local res = parse_url(bufname) + res.path = pathutil.parent(res.path) + return url_to_str(res) +end + +---@param url string +---@param callback fun(url: string) +M.normalize_url = function(url, callback) + local res = parse_url(url) + local conn = get_connection(url, true) + + local path = res.path + if path == "" then + path = "." + end + + local cmd = string.format( + 'if ! readlink -f "%s" 2>/dev/null; then [[ "%s" == /* ]] && echo "%s" || echo "$PWD/%s"; fi', + path, + path, + path, + path + ) + conn:run(cmd, function(err, lines) + if err then + vim.notify(string.format("Error normalizing url %s: %s", url, err), vim.log.levels.WARN) + return callback(url) + end + local abspath = table.concat(lines, "") + if vim.endswith(abspath, ".") then + abspath = abspath:sub(1, #abspath - 1) + end + abspath = util.addslash(abspath) + if abspath == res.path then + callback(url) + else + res.path = abspath + callback(url_to_str(res)) + end + end) +end + +local dir_meta = {} + +---@param url string +---@param column_defs string[] +---@param callback fun(err: nil|string, entries: nil|oil.InternalEntry[]) +M.list = function(url, column_defs, callback) + local res = parse_url(url) + + local path_postfix = "" + if res.path ~= "" then + path_postfix = string.format(" '%s'", res.path) + end + local conn = get_connection(url) + cache.begin_update_url(url) + local function cb(err, data) + if err or not data then + cache.end_update_url(url) + end + callback(err, data) + end + conn:run("ls -fl" .. path_postfix, function(err, lines) + if err then + if err:match("No such file or directory%s*$") then + -- If the directory doesn't exist, treat the list as a success. We will be able to traverse + -- and edit a not-yet-existing directory. + return cb() + else + return cb(err) + end + end + local any_links = false + local entries = {} + for _, line in ipairs(lines) do + if line ~= "" and not line:match("^total") then + local name, type, meta = parse_ls_line(line) + if name == "." then + dir_meta[url] = meta + elseif name ~= ".." then + if type == "link" then + any_links = true + end + local cache_entry = cache.create_entry(url, name, type) + entries[name] = cache_entry + cache_entry[FIELD.meta] = meta + cache.store_entry(url, cache_entry) + end + end + end + if any_links then + -- If there were any soft links, then we need to run another ls command with -L so that we can + -- resolve the type of the link target + conn:run("ls -fLl" .. path_postfix, function(link_err, link_lines) + -- Ignore exit code 1. That just means one of the links could not be resolved. + if link_err and not link_err:match("^1:") then + return cb(link_err) + end + for _, line in ipairs(link_lines) do + if line ~= "" and not line:match("^total") then + local ok, name, type, meta = pcall(parse_ls_line, line) + if ok and name ~= "." and name ~= ".." then + local cache_entry = entries[name] + if cache_entry[FIELD.type] == "link" then + cache_entry[FIELD.meta].link_stat = { + type = type, + size = meta.size, + } + end + end + end + end + cb() + end) + else + cb() + end + end) +end + +---@param bufnr integer +---@return boolean +M.is_modifiable = function(bufnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local meta = dir_meta[bufname] + if not meta then + -- Directories that don't exist yet are modifiable + return true + end + local conn = get_connection(bufname) + if not conn.meta.user or not conn.meta.groups then + return false + end + local rwx + if meta.user == conn.meta.user then + rwx = bit.rshift(meta.mode, 6) + elseif vim.tbl_contains(conn.meta.groups, meta.group) then + rwx = bit.rshift(meta.mode, 3) + else + rwx = meta.mode + end + return bit.band(rwx, 2) ~= 0 +end + +---@param url string +M.url_to_buffer_name = function(url) + local _, rem = util.parse_url(url) + -- Let netrw handle editing files + return "scp://" .. rem +end + +---@param action oil.Action +---@return string +M.render_action = function(action) + if action.type == "create" then + local ret = string.format("CREATE %s", action.url) + if action.link then + ret = ret .. " -> " .. action.link + end + return ret + elseif action.type == "delete" then + return string.format("DELETE %s", action.url) + elseif action.type == "move" or action.type == "copy" then + local src = action.src_url + local dest = action.dest_url + if config.get_adapter_by_scheme(src) == M then + local _, path = util.parse_url(dest) + dest = files.to_short_os_path(path, action.entry_type) + else + local _, path = util.parse_url(src) + src = files.to_short_os_path(path, action.entry_type) + end + return string.format(" %s %s -> %s", action.type:upper(), src, dest) + else + error(string.format("Bad action type: '%s'", action.type)) + end +end + +---@param action oil.Action +---@param cb fun(err: nil|string) +M.perform_action = function(action, cb) + if action.type == "create" then + local res = parse_url(action.url) + local conn = get_connection(action.url) + if action.entry_type == "directory" then + conn:run(string.format("mkdir -p '%s'", res.path), cb) + elseif action.entry_type == "link" and action.link then + conn:run(string.format("ln -s '%s' '%s'", action.link, res.path), cb) + else + conn:run(string.format("touch '%s'", res.path), cb) + end + elseif action.type == "delete" then + local res = parse_url(action.url) + local conn = get_connection(action.url) + conn:run(string.format("rm -rf '%s'", res.path), cb) + elseif action.type == "move" then + local src_adapter = config.get_adapter_by_scheme(action.src_url) + local dest_adapter = config.get_adapter_by_scheme(action.dest_url) + if src_adapter == M and dest_adapter == M then + local src_res = parse_url(action.src_url) + local dest_res = parse_url(action.dest_url) + local src_conn = get_connection(action.src_url) + local dest_conn = get_connection(action.dest_url) + if src_conn ~= dest_conn then + shell.run({ "scp", "-r", url_to_scp(src_res), url_to_scp(dest_res) }, function(err) + if err then + return cb(err) + end + src_conn:run(string.format("rm -rf '%s'", src_res.path), cb) + end) + else + src_conn:run(string.format("mv '%s' '%s'", src_res.path, dest_res.path), cb) + end + else + cb("We should never attempt to move across adapters") + end + elseif action.type == "copy" then + local src_adapter = config.get_adapter_by_scheme(action.src_url) + local dest_adapter = config.get_adapter_by_scheme(action.dest_url) + if src_adapter == M and dest_adapter == M then + local src_res = parse_url(action.src_url) + local dest_res = parse_url(action.dest_url) + local src_conn = get_connection(action.src_url) + local dest_conn = get_connection(action.dest_url) + if src_conn.host ~= dest_conn.host then + shell.run({ "scp", "-r", url_to_scp(src_res), url_to_scp(dest_res) }, cb) + end + src_conn:run(string.format("cp -r '%s' '%s'", src_res.path, dest_res.path), cb) + else + local src_arg + local dest_arg + if src_adapter == M then + src_arg = url_to_scp(parse_url(action.src_url)) + local _, path = util.parse_url(action.dest_url) + dest_arg = fs.posix_to_os_path(path) + else + local _, path = util.parse_url(action.src_url) + src_arg = fs.posix_to_os_path(path) + dest_arg = url_to_scp(parse_url(action.dest_url)) + end + shell.run({ "scp", "-r", src_arg, dest_arg }, cb) + end + else + cb(string.format("Bad action type: %s", action.type)) + end +end + +M.supports_xfer = { files = true } + +return M diff --git a/lua/oil/adapters/ssh/connection.lua b/lua/oil/adapters/ssh/connection.lua new file mode 100644 index 0000000..eac244a --- /dev/null +++ b/lua/oil/adapters/ssh/connection.lua @@ -0,0 +1,270 @@ +local util = require("oil.util") +local SSHConnection = {} + +local function output_extend(agg, output) + local start = #agg + if vim.tbl_isempty(agg) then + for _, line in ipairs(output) do + line = line:gsub("\r", "") + table.insert(agg, line) + end + else + for i, v in ipairs(output) do + v = v:gsub("\r", "") + if i == 1 then + agg[#agg] = agg[#agg] .. v + else + table.insert(agg, v) + end + end + end + return start +end + +---@param bufnr integer +---@param num_lines integer +---@return string[] +local function get_last_lines(bufnr, num_lines) + local end_line = vim.api.nvim_buf_line_count(bufnr) + num_lines = math.min(num_lines, end_line) + local lines = {} + while end_line > 0 and #lines < num_lines do + local need_lines = num_lines - #lines + lines = vim.list_extend( + vim.api.nvim_buf_get_lines(bufnr, end_line - need_lines, end_line, false), + lines + ) + while not vim.tbl_isempty(lines) and lines[#lines]:match("^%s*$") do + table.remove(lines) + end + end_line = end_line - need_lines + end + return lines +end + +---@param url oil.sshUrl +function SSHConnection.new(url) + local host = url.host + if url.user then + host = url.user .. "@" .. host + end + if url.port then + host = string.format("%s:%d", host, url.port) + end + local command = { + "ssh", + host, + "/bin/bash", + "--norc", + "-c", + "echo '_make_newline_'; echo '===READY==='; exec /bin/bash --norc", + } + local self = setmetatable({ + host = host, + meta = {}, + commands = {}, + connected = false, + connection_error = nil, + }, { + __index = SSHConnection, + }) + + self.term_bufnr = vim.api.nvim_create_buf(false, true) + local term_id + local mode = vim.api.nvim_get_mode().mode + util.run_in_fullscreen_win(self.term_bufnr, function() + term_id = vim.api.nvim_open_term(self.term_bufnr, { + on_input = function(_, _, _, data) + pcall(vim.api.nvim_chan_send, self.jid, data) + end, + }) + end) + self.term_id = term_id + vim.api.nvim_chan_send(term_id, string.format("ssh %s\r\n", host)) + util.hack_around_termopen_autocmd(mode) + + -- If it takes more than 2 seconds to connect, pop open the terminal + vim.defer_fn(function() + if not self.connected and not self.connection_error then + self:open_terminal() + end + end, 2000) + self._stdout = {} + local jid = vim.fn.jobstart(command, { + pty = true, -- This is require for interactivity + on_stdout = function(j, output) + pcall(vim.api.nvim_chan_send, self.term_id, table.concat(output, "\r\n")) + local new_i_start = output_extend(self._stdout, output) + self:_handle_output(new_i_start) + end, + on_exit = function(j, code) + -- Defer to allow the deferred terminal output handling to kick in first + vim.defer_fn(function() + if code == 0 then + self:_set_connection_error("SSH connection terminated gracefully") + else + self:_set_connection_error("Unknown SSH error") + end + end, 20) + end, + }) + local exe = command[1] + if jid == 0 then + self:_set_connection_error(string.format("Passed invalid arguments to '%s'", exe)) + elseif jid == -1 then + self:_set_connection_error(string.format("'%s' is not executable", exe)) + else + self.jid = jid + end + self:run("whoami", function(err, lines) + if err then + vim.notify(string.format("Error fetching ssh connection user: %s", err), vim.log.levels.WARN) + else + self.meta.user = vim.trim(table.concat(lines, "")) + end + end) + self:run("groups", function(err, lines) + if err then + vim.notify( + string.format("Error fetching ssh connection user groups: %s", err), + vim.log.levels.WARN + ) + else + self.meta.groups = vim.split(table.concat(lines, ""), "%s+", { trimempty = true }) + end + end) + + return self +end + +---@param err string +function SSHConnection:_set_connection_error(err) + if self.connection_error then + return + end + self.connection_error = err + local commands = self.commands + self.commands = {} + for _, cmd in ipairs(commands) do + cmd.cb(err) + end +end + +function SSHConnection:_handle_output(start_i) + if not self.connected then + for i = start_i, #self._stdout - 1 do + local line = self._stdout[i] + if line == "===READY===" then + if self.term_winid then + if vim.api.nvim_win_is_valid(self.term_winid) then + vim.api.nvim_win_close(self.term_winid, true) + end + self.term_winid = nil + end + self.connected = true + self._stdout = util.tbl_slice(self._stdout, i + 1) + self:_handle_output(1) + self:_consume() + return + end + end + else + for i = start_i, #self._stdout - 1 do + local line = self._stdout[i] + if line:match("^===BEGIN===%s*$") then + self._stdout = util.tbl_slice(self._stdout, i + 1) + self:_handle_output(1) + return + end + -- We can't be as strict with the matching (^$) because since we're using a pty the stdout and + -- stderr can be interleaved. If the command had an error, the stderr may interfere with a + -- clean print of the done line. + local exit_code = line:match("===DONE%((%d+)%)===") + if exit_code then + local output = util.tbl_slice(self._stdout, 1, i - 1) + local cb = self.commands[1].cb + self._stdout = util.tbl_slice(self._stdout, i + 1) + if exit_code == "0" then + cb(nil, output) + else + cb(exit_code .. ": " .. table.concat(output, "\n"), output) + end + table.remove(self.commands, 1) + self:_handle_output(1) + self:_consume() + return + end + end + end + + local function check_last_line() + local last_lines = get_last_lines(self.term_bufnr, 1) + local last_line = last_lines[1] + if last_line:match("^Are you sure you want to continue connecting") then + self:open_terminal() + elseif last_line:match("Password:%s*$") then + self:open_terminal() + elseif last_line:match(": Permission denied %(.+%)%.") then + self:_set_connection_error(last_line:match(": (Permission denied %(.+%).)")) + elseif last_line:match("^ssh: .*Connection refused%s*$") then + self:_set_connection_error("Connection refused") + elseif last_line:match("^Connection to .+ closed by remote host.%s*$") then + self:_set_connection_error("Connection closed by remote host") + end + end + -- We have to defer this so the terminal buffer has time to update + vim.defer_fn(check_last_line, 10) +end + +function SSHConnection:open_terminal() + if self.term_winid and vim.api.nvim_win_is_valid(self.term_winid) then + vim.api.nvim_set_current_win(self.term_winid) + return + end + local min_width = 120 + local min_height = 20 + local total_height = util.get_editor_height() + local width = math.min(min_width, vim.o.columns - 2) + local height = math.min(min_height, total_height - 3) + local row = math.floor((util.get_editor_height() - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + self.term_winid = vim.api.nvim_open_win(self.term_bufnr, true, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + }) + vim.cmd.startinsert() +end + +---@param command string +---@param callback fun(err: nil|string, lines: nil|string[]) +function SSHConnection:run(command, callback) + if self.connection_error then + callback(self.connection_error) + else + table.insert(self.commands, { cmd = command, cb = callback }) + self:_consume() + end +end + +function SSHConnection:_consume() + if self.connected and not vim.tbl_isempty(self.commands) then + local cmd = self.commands[1] + if not cmd.running then + cmd.running = true + vim.api.nvim_chan_send( + self.jid, + -- HACK: Sleep briefly to help reduce stderr/stdout interleaving. + -- I want to find a way to flush the stderr before the echo DONE, but haven't yet. + -- This was causing issues when ls directory that doesn't exist (b/c ls prints error) + 'echo "===BEGIN==="; ' .. cmd.cmd .. '; CODE=$?; sleep .01; echo "===DONE($CODE)==="\r' + ) + end + end +end + +return SSHConnection diff --git a/lua/oil/adapters/test.lua b/lua/oil/adapters/test.lua new file mode 100644 index 0000000..0f4d4a7 --- /dev/null +++ b/lua/oil/adapters/test.lua @@ -0,0 +1,62 @@ +local cache = require("oil.cache") +local M = {} + +---@param path string +---@param column_defs string[] +---@param cb fun(err: nil|string, entries: nil|oil.InternalEntry[]) +M.list = function(url, column_defs, cb) + cb(nil, cache.list_url(url)) +end + +---@param name string +---@return nil|oil.ColumnDefinition +M.get_column = function(name) + return nil +end + +---@param path string +---@param entry_type oil.EntryType +M.test_set = function(path, entry_type) + local parent = vim.fn.fnamemodify(path, ":h") + if parent ~= path then + M.test_set(parent, "directory") + end + local url = "oil-test://" .. path + if cache.get_entry_by_url(url) then + -- Already exists + return + end + local name = vim.fn.fnamemodify(path, ":t") + cache.create_and_store_entry("oil-test://" .. parent, name, entry_type) +end + +---@param bufnr integer +---@return boolean +M.is_modifiable = function(bufnr) + return true +end + +---@param url string +M.url_to_buffer_name = function(url) + error("Test adapter cannot open files") +end + +---@param action oil.Action +---@return string +M.render_action = function(action) + if action.type == "create" or action.type == "delete" then + return string.format("%s %s", action.type:upper(), action.url) + elseif action.type == "move" or action.type == "copy" then + return string.format(" %s %s -> %s", action.type:upper(), action.src_url, action.dest_url) + else + error("Bad action type") + end +end + +---@param action oil.Action +---@param cb fun(err: nil|string) +M.perform_action = function(action, cb) + cb() +end + +return M diff --git a/lua/oil/cache.lua b/lua/oil/cache.lua new file mode 100644 index 0000000..e878fc7 --- /dev/null +++ b/lua/oil/cache.lua @@ -0,0 +1,183 @@ +local util = require("oil.util") +local FIELD = require("oil.constants").FIELD +local M = {} + +local next_id = 1 + +-- Map> +local url_directory = {} + +---@type table +local entries_by_id = {} + +---@type table +local parent_url_by_id = {} + +-- Temporary map while a directory is being updated +local tmp_url_directory = {} + +local _cached_id_fmt + +---@param id integer +---@return string +M.format_id = function(id) + if not _cached_id_fmt then + local id_str_length = math.max(3, 1 + math.floor(math.log10(next_id))) + _cached_id_fmt = "/%0" .. string.format("%d", id_str_length) .. "d" + end + return _cached_id_fmt:format(id) +end + +M.clear_everything = function() + next_id = 1 + url_directory = {} + entries_by_id = {} + parent_url_by_id = {} +end + +---@param parent_url string +---@param name string +---@param type oil.EntryType +---@return oil.InternalEntry +M.create_entry = function(parent_url, name, type) + parent_url = util.addslash(parent_url) + local parent = tmp_url_directory[parent_url] or url_directory[parent_url] + local entry + if parent then + entry = parent[name] + end + if entry then + return entry + end + local id = next_id + next_id = next_id + 1 + _cached_id_fmt = nil + return { id, name, type } +end + +---@param parent_url string +---@param entry oil.InternalEntry +M.store_entry = function(parent_url, entry) + parent_url = util.addslash(parent_url) + local parent = url_directory[parent_url] + if not parent then + parent = {} + url_directory[parent_url] = parent + end + local id = entry[FIELD.id] + local name = entry[FIELD.name] + parent[name] = entry + local tmp_dir = tmp_url_directory[parent_url] + if tmp_dir and tmp_dir[name] then + tmp_dir[name] = nil + end + entries_by_id[id] = entry + parent_url_by_id[id] = parent_url +end + +---@param parent_url string +---@param name string +---@param type oil.EntryType +---@return oil.InternalEntry +M.create_and_store_entry = function(parent_url, name, type) + local entry = M.create_entry(parent_url, name, type) + M.store_entry(parent_url, entry) + return entry +end + +M.begin_update_url = function(parent_url) + parent_url = util.addslash(parent_url) + tmp_url_directory[parent_url] = url_directory[parent_url] + url_directory[parent_url] = {} +end + +M.end_update_url = function(parent_url) + parent_url = util.addslash(parent_url) + if not tmp_url_directory[parent_url] then + return + end + for _, old_entry in pairs(tmp_url_directory[parent_url]) do + local id = old_entry[FIELD.id] + parent_url_by_id[id] = nil + entries_by_id[id] = nil + end + tmp_url_directory[parent_url] = nil +end + +---@param id integer +---@return nil|oil.InternalEntry +M.get_entry_by_id = function(id) + return entries_by_id[id] +end + +---@param id integer +---@return string +M.get_parent_url = function(id) + local url = parent_url_by_id[id] + if not url then + error(string.format("Entry %d missing parent url", id)) + end + return url +end + +---@param url string +---@return oil.InternalEntry[] +M.list_url = function(url) + url = util.addslash(url) + return url_directory[url] or {} +end + +M.get_entry_by_url = function(url) + local parent, name = url:match("^(.+)/([^/]+)$") + local cache = url_directory[parent] + return cache and cache[name] +end + +---@param oil.Action +M.perform_action = function(action) + if action.type == "create" then + local scheme, path = util.parse_url(action.url) + local parent_url = util.addslash(scheme .. vim.fn.fnamemodify(path, ":h")) + local name = vim.fn.fnamemodify(path, ":t") + M.create_and_store_entry(parent_url, name, action.entry_type) + elseif action.type == "delete" then + local scheme, path = util.parse_url(action.url) + local parent_url = util.addslash(scheme .. vim.fn.fnamemodify(path, ":h")) + local name = vim.fn.fnamemodify(path, ":t") + local entry = url_directory[parent_url][name] + url_directory[parent_url][name] = nil + entries_by_id[entry[FIELD.id]] = nil + parent_url_by_id[entry[FIELD.id]] = nil + elseif action.type == "move" then + local src_scheme, src_path = util.parse_url(action.src_url) + local src_parent_url = util.addslash(src_scheme .. vim.fn.fnamemodify(src_path, ":h")) + local src_name = vim.fn.fnamemodify(src_path, ":t") + local entry = url_directory[src_parent_url][src_name] + + local dest_scheme, dest_path = util.parse_url(action.dest_url) + local dest_parent_url = util.addslash(dest_scheme .. vim.fn.fnamemodify(dest_path, ":h")) + local dest_name = vim.fn.fnamemodify(dest_path, ":t") + + url_directory[src_parent_url][src_name] = nil + local dest_parent = url_directory[dest_parent_url] + if not dest_parent then + dest_parent = {} + url_directory[dest_parent_url] = dest_parent + end + dest_parent[dest_name] = entry + parent_url_by_id[entry[FIELD.id]] = dest_parent_url + entry[FIELD.name] = dest_name + util.update_moved_buffers(action.entry_type, action.src_url, action.dest_url) + elseif action.type == "copy" then + local scheme, path = util.parse_url(action.dest_url) + local parent_url = util.addslash(scheme .. vim.fn.fnamemodify(path, ":h")) + local name = vim.fn.fnamemodify(path, ":t") + M.create_and_store_entry(parent_url, name, action.entry_type) + elseif action.type == "change" then + -- Cache doesn't need to update + else + error(string.format("Bad action type: '%s'", action.type)) + end +end + +return M diff --git a/lua/oil/columns.lua b/lua/oil/columns.lua new file mode 100644 index 0000000..0b985bb --- /dev/null +++ b/lua/oil/columns.lua @@ -0,0 +1,238 @@ +local config = require("oil.config") +local util = require("oil.util") +local has_devicons, devicons = pcall(require, "nvim-web-devicons") +local FIELD = require("oil.constants").FIELD +local M = {} + +local all_columns = {} + +---@alias oil.ColumnSpec string|table + +---@class oil.ColumnDefinition +---@field render fun(entry: oil.InternalEntry, conf: nil|table): nil|oil.TextChunk +---@field parse fun(line: string, conf: nil|table): nil|string, nil|string +---@field meta_fields nil|table + +---@param name string +---@param column oil.ColumnDefinition +M.register = function(name, column) + all_columns[name] = column +end + +---@param adapter oil.Adapter +---@param defn oil.ColumnSpec +---@return nil|oil.ColumnDefinition +local function get_column(adapter, defn) + local name = util.split_config(defn) + return all_columns[name] or adapter.get_column(name) +end + +---@param scheme string +---@return oil.ColumnSpec[] +M.get_supported_columns = function(scheme) + local ret = {} + local adapter = config.get_adapter_by_scheme(scheme) + for _, def in ipairs(config.columns) do + if get_column(adapter, def) then + table.insert(ret, def) + end + end + return ret +end + +---@param adapter oil.Adapter +---@param column_defs table[] +---@return fun(parent_url: string, entry: oil.InternalEntry, cb: fun(err: nil|string)) +M.get_metadata_fetcher = function(adapter, column_defs) + local keyfetches = {} + local num_keys = 0 + for _, def in ipairs(column_defs) do + local name = util.split_config(def) + local column = get_column(adapter, name) + if column and column.meta_fields then + for k, v in pairs(column.meta_fields) do + if not keyfetches[k] then + keyfetches[k] = v + num_keys = num_keys + 1 + end + end + end + end + if num_keys == 0 then + return function(_, _, cb) + cb() + end + end + return function(parent_url, entry, cb) + cb = util.cb_collect(num_keys, cb) + local meta = {} + entry[FIELD.meta] = meta + for k, v in pairs(keyfetches) do + v(parent_url, entry, function(err, value) + if err then + cb(err) + else + meta[k] = value + cb() + end + end) + end + end +end + +local EMPTY = { "-", "Comment" } + +---@param adapter oil.Adapter +---@param col_def oil.ColumnSpec +---@param entry oil.InternalEntry +---@return oil.TextChunk +M.render_col = function(adapter, col_def, entry) + local name, conf = util.split_config(col_def) + local column = get_column(adapter, name) + if not column then + -- This shouldn't be possible because supports_col should return false + return EMPTY + end + + -- Make sure all the required metadata exists before attempting to render + if column.meta_fields then + local meta = entry[FIELD.meta] + if not meta then + return EMPTY + end + for k in pairs(column.meta_fields) do + if not meta[k] then + return EMPTY + end + end + end + local chunk = column.render(entry, conf) + if type(chunk) == "table" then + if chunk[1]:match("^%s*$") then + return EMPTY + end + else + if not chunk or chunk:match("^%s*$") then + return EMPTY + end + if conf and conf.highlight then + local highlight = conf.highlight + if type(highlight) == "function" then + highlight = conf.highlight(chunk) + end + return { chunk, highlight } + end + end + return chunk +end + +---@param adapter oil.Adapter +---@param line string +---@param col_def oil.ColumnSpec +---@return nil|string +---@return nil|string +M.parse_col = function(adapter, line, col_def) + local name, conf = util.split_config(col_def) + -- If rendering failed, there will just be a "-" + if vim.startswith(line, "- ") then + return nil, line:sub(3) + end + local column = get_column(adapter, name) + if column then + return column.parse(line, conf) + end +end + +---@param adapter oil.Adapter +---@param col_name string +---@param entry oil.InternalEntry +---@param parsed_value any +---@return boolean +M.compare = function(adapter, col_name, entry, parsed_value) + local column = get_column(adapter, col_name) + if column and column.compare then + return column.compare(entry, parsed_value) + else + return false + end +end + +---@param adapter oil.Adapter +---@param action oil.ChangeAction +---@return string +M.render_change_action = function(adapter, action) + local column = get_column(adapter, action.column) + if not column then + error(string.format("Received change action for nonexistant column %s", action.column)) + end + if column.render_action then + return column.render_action(action) + else + return string.format("CHANGE %s %s = %s", action.url, action.column, action.value) + end +end + +---@param adapter oil.Adapter +---@param action oil.ChangeAction +---@param callback fun(err: nil|string) +M.perform_change_action = function(adapter, action, callback) + local column = get_column(adapter, action.column) + if not column then + return callback( + string.format("Received change action for nonexistant column %s", action.column) + ) + end + column.perform_action(action, callback) +end + +if has_devicons then + M.register("icon", { + render = function(entry, conf) + local type = entry[FIELD.type] + local name = entry[FIELD.name] + local meta = entry[FIELD.meta] + if type == "link" and meta then + if meta.link then + name = meta.link + end + if meta.link_stat then + type = meta.link_stat.type + end + end + if type == "directory" then + return { " ", "OilDir" } + else + local icon + local hl + icon, hl = devicons.get_icon(name) + icon = icon or "?" + return { icon .. " ", hl } + end + end, + + parse = function(line, conf) + return line:match("^(%S+)%s+(.*)$") + end, + }) +end + +local default_type_icons = { + directory = "dir", + socket = "sock", +} +M.register("type", { + render = function(entry, conf) + local entry_type = entry[FIELD.type] + if conf and conf.icons then + return conf.icons[entry_type] or entry_type + else + return default_type_icons[entry_type] or entry_type + end + end, + + parse = function(line, conf) + return line:match("^(%S+)%s+(.*)$") + end, +}) + +return M diff --git a/lua/oil/config.lua b/lua/oil/config.lua new file mode 100644 index 0000000..9048cee --- /dev/null +++ b/lua/oil/config.lua @@ -0,0 +1,154 @@ +local default_config = { + -- Id is automatically added at the beginning, and name at the end + -- See :help oil-columns + columns = { + "icon", + -- "permissions", + -- "size", + -- "mtime", + }, + -- Window-local options to use for oil buffers + win_options = { + wrap = false, + signcolumn = "no", + cursorcolumn = false, + foldcolumn = "0", + spell = false, + list = false, + conceallevel = 3, + concealcursor = "n", + }, + -- Restore window options to previous values when leaving an oil buffer + restore_win_options = true, + -- Skip the confirmation popup for simple operations + skip_confirm_for_simple_edits = false, + -- Keymaps in oil buffer. Can be any value that `vim.keymap.set` accepts OR a table of keymap + -- options with a `callback` (e.g. { callback = function() ... end, desc = "", nowait = true }) + -- Additionally, if it is a string that matches "action.", + -- it will use the mapping at require("oil.action"). + -- Set to `false` to remove a keymap + keymaps = { + ["g?"] = "actions.show_help", + [""] = "actions.select", + [""] = "actions.select_vsplit", + [""] = "actions.select_split", + [""] = "actions.preview", + [""] = "actions.close", + ["-"] = "actions.parent", + ["_"] = "actions.open_cwd", + ["`"] = "actions.cd", + ["~"] = "actions.tcd", + ["g."] = "actions.toggle_hidden", + }, + view_options = { + -- Show files and directories that start with "." + show_hidden = false, + }, + -- Configuration for the floating window in oil.open_float + float = { + -- Padding around the floating window + padding = 2, + max_width = 0, + max_height = 0, + border = "rounded", + win_options = { + winblend = 10, + }, + }, + adapters = { + ["oil://"] = "files", + ["oil-ssh://"] = "ssh", + }, + -- When opening the parent of a file, substitute these url schemes + remap_schemes = { + ["scp://"] = "oil-ssh://", + ["sftp://"] = "oil-ssh://", + }, +} + +local M = {} + +M.setup = function(opts) + local new_conf = vim.tbl_deep_extend("keep", opts or {}, default_config) + + for k, v in pairs(new_conf) do + M[k] = v + end + + vim.tbl_add_reverse_lookup(M.adapters) + M._adapter_by_scheme = {} + if type(M.trash) == "string" then + M.trash = vim.fn.fnamemodify(vim.fn.expand(M.trash), ":p") + end +end + +---@return nil|string +M.get_trash_url = function() + if not M.trash then + return nil + end + local fs = require("oil.fs") + if M.trash == true then + local data_home = os.getenv("XDG_DATA_HOME") or vim.fn.expand("~/.local/share") + local preferred = fs.join(data_home, "trash") + local candidates = { + preferred, + } + if fs.is_windows then + -- TODO permission issues when using the recycle bin. The folder gets created without + -- read/write perms, so all operations fail + -- local cwd = vim.fn.getcwd() + -- table.insert(candidates, 1, cwd:sub(1, 3) .. "$Recycle.Bin") + -- table.insert(candidates, 1, "C:\\$Recycle.Bin") + else + table.insert(candidates, fs.join(data_home, "Trash", "files")) + table.insert(candidates, fs.join(os.getenv("HOME"), ".Trash")) + end + local trash_dir = preferred + for _, candidate in ipairs(candidates) do + if vim.fn.isdirectory(candidate) == 1 then + trash_dir = candidate + break + end + end + + local oil_trash_dir = vim.fn.fnamemodify(fs.join(trash_dir, "nvim", "oil"), ":p") + fs.mkdirp(oil_trash_dir) + M.trash = oil_trash_dir + end + return M.adapters.files .. fs.os_to_posix_path(M.trash) +end + +---@param scheme string +---@return nil|oil.Adapter +M.get_adapter_by_scheme = function(scheme) + if not vim.endswith(scheme, "://") then + local pieces = vim.split(scheme, "://", { plain = true }) + if #pieces <= 2 then + scheme = pieces[1] .. "://" + else + error(string.format("Malformed url: '%s'", scheme)) + end + end + local adapter = M._adapter_by_scheme[scheme] + if adapter == nil then + local name = M.adapters[scheme] + local ok + ok, adapter = pcall(require, string.format("oil.adapters.%s", name)) + if ok then + adapter.name = name + M._adapter_by_scheme[scheme] = adapter + else + M._adapter_by_scheme[scheme] = false + adapter = false + vim.notify(string.format("Could not find oil adapter '%s'", name), vim.log.levels.ERROR) + end + end + if adapter then + return adapter + else + return nil + end +end + +return M diff --git a/lua/oil/constants.lua b/lua/oil/constants.lua new file mode 100644 index 0000000..a83ee67 --- /dev/null +++ b/lua/oil/constants.lua @@ -0,0 +1,10 @@ +local M = {} + +M.FIELD = { + id = 1, + name = 2, + type = 3, + meta = 4, +} + +return M diff --git a/lua/oil/fs.lua b/lua/oil/fs.lua new file mode 100644 index 0000000..7f86cc2 --- /dev/null +++ b/lua/oil/fs.lua @@ -0,0 +1,256 @@ +local M = {} + +---@type boolean +M.is_windows = vim.loop.os_uname().version:match("Windows") + +---@type string +M.sep = M.is_windows and "\\" or "/" + +---@param ... string +M.join = function(...) + return table.concat({ ... }, M.sep) +end + +---Check if OS path is absolute +---@param dir string +---@return boolean +M.is_absolute = function(dir) + if M.is_windows then + return dir:match("^%a:\\") + else + return vim.startswith(dir, "/") + end +end + +---@param path string +---@param cb fun(err: nil|string) +M.touch = function(path, cb) + vim.loop.fs_open(path, "a", 420, function(err, fd) -- 0644 + if err then + cb(err) + else + vim.loop.fs_close(fd, cb) + end + end) +end + +---@param path string +---@return string +M.posix_to_os_path = function(path) + if M.is_windows then + if vim.startswith(path, "/") then + local drive, rem = path:match("^/([^/]+)/(.*)$") + return string.format("%s:\\%s", drive, rem:gsub("/", "\\")) + else + return path:gsub("/", "\\") + end + else + return path + end +end + +---@param path string +---@return string +M.os_to_posix_path = function(path) + if M.is_windows then + if M.is_absolute(path) then + local drive, rem = path:match("^([^:]+):\\(.*)$") + return string.format("/%s/%s", drive, rem:gsub("\\", "/")) + else + return path:gsub("\\", "/") + end + else + return path + end +end + +local home_dir = vim.loop.os_homedir() + +---@param path string +---@return string +M.shorten_path = function(path) + local cwd = vim.fn.getcwd() + if vim.startswith(path, cwd) then + return path:sub(cwd:len() + 2) + end + if vim.startswith(path, home_dir) then + return "~" .. path:sub(home_dir:len() + 1) + end + return path +end + +M.mkdirp = function(dir) + local mod = "" + local path = dir + while vim.fn.isdirectory(path) == 0 do + mod = mod .. ":h" + path = vim.fn.fnamemodify(dir, mod) + end + while mod ~= "" do + mod = mod:sub(3) + path = vim.fn.fnamemodify(dir, mod) + vim.loop.fs_mkdir(path, 493) + end +end + +---@param dir string +---@param cb fun(err: nil|string, entries: nil|{type: oil.EntryType, name: string}) +M.listdir = function(dir, cb) + vim.loop.fs_opendir(dir, function(open_err, fd) + if open_err then + return cb(open_err) + end + local read_next + read_next = function() + vim.loop.fs_readdir(fd, function(err, entries) + if err then + vim.loop.fs_closedir(fd, function() + cb(err) + end) + return + elseif entries then + cb(nil, entries) + read_next() + else + vim.loop.fs_closedir(fd, function(close_err) + if close_err then + cb(close_err) + else + cb() + end + end) + end + end) + end + read_next() + end, 100) -- TODO do some testing for this +end + +---@param entry_type oil.EntryType +---@param path string +---@param cb fun(err: nil|string) +M.recursive_delete = function(entry_type, path, cb) + if entry_type ~= "directory" then + return vim.loop.fs_unlink(path, cb) + end + vim.loop.fs_opendir(path, function(open_err, fd) + if open_err then + return cb(open_err) + end + local poll + poll = function(inner_cb) + vim.loop.fs_readdir(fd, function(err, entries) + if err then + return inner_cb(err) + elseif entries then + local waiting = #entries + local complete + complete = function(err2) + if err then + complete = function() end + return inner_cb(err2) + end + waiting = waiting - 1 + if waiting == 0 then + poll(inner_cb) + end + end + for _, entry in ipairs(entries) do + M.recursive_delete(entry.type, path .. M.sep .. entry.name, complete) + end + else + inner_cb() + end + end) + end + poll(function(err) + vim.loop.fs_closedir(fd) + if err then + return cb(err) + end + vim.loop.fs_rmdir(path, cb) + end) + end, 100) -- TODO do some testing for this +end + +---@param entry_type oil.EntryType +---@param src_path string +---@param dest_path string +---@param cb fun(err: nil|string) +M.recursive_copy = function(entry_type, src_path, dest_path, cb) + if entry_type ~= "directory" then + vim.loop.fs_copyfile(src_path, dest_path, { excl = true }, cb) + return + end + vim.loop.fs_stat(src_path, function(stat_err, src_stat) + if stat_err then + return cb(stat_err) + end + vim.loop.fs_mkdir(dest_path, src_stat.mode, function(mkdir_err) + if mkdir_err then + return cb(mkdir_err) + end + vim.loop.fs_opendir(src_path, function(open_err, fd) + if open_err then + return cb(open_err) + end + local poll + poll = function(inner_cb) + vim.loop.fs_readdir(fd, function(err, entries) + if err then + return inner_cb(err) + elseif entries then + local waiting = #entries + local complete + complete = function(err2) + if err then + complete = function() end + return inner_cb(err2) + end + waiting = waiting - 1 + if waiting == 0 then + poll(inner_cb) + end + end + for _, entry in ipairs(entries) do + M.recursive_copy( + entry.type, + src_path .. M.sep .. entry.name, + dest_path .. M.sep .. entry.name, + complete + ) + end + else + inner_cb() + end + end) + end + poll(cb) + end, 100) -- TODO do some testing for this + end) + end) +end + +---@param entry_type oil.EntryType +---@param src_path string +---@param dest_path string +---@param cb fun(err: nil|string) +M.recursive_move = function(entry_type, src_path, dest_path, cb) + vim.loop.fs_rename(src_path, dest_path, function(err) + if err then + -- fs_rename fails for cross-partition or cross-device operations. + -- We then fall back to a copy + delete + M.recursive_copy(entry_type, src_path, dest_path, function(err2) + if err2 then + cb(err2) + else + M.recursive_delete(entry_type, src_path, cb) + end + end) + else + cb() + end + end) +end + +return M diff --git a/lua/oil/init.lua b/lua/oil/init.lua new file mode 100644 index 0000000..66e6661 --- /dev/null +++ b/lua/oil/init.lua @@ -0,0 +1,582 @@ +local M = {} + +---@alias oil.InternalEntry string[] + +---@class oil.Entry +---@field name string +---@field type oil.EntryType +---@field id nil|string Will be nil if it hasn't been persisted to disk yet + +---@alias oil.EntryType "file"|"directory"|"socket"|"link" +---@alias oil.TextChunk string|string[] + +---@class oil.Adapter +---@field list fun(path: string, cb: fun(err: nil|string, entries: nil|oil.InternalEntry[])) +---@field is_modifiable fun(bufnr: integer): boolean +---@field url_to_buffer_name fun(url: string): string +---@field get_column fun(name: string): nil|oil.ColumnDefinition +---@field normalize_url nil|fun(url: string, callback: fun(url: string)) +---@field get_parent nil|fun(bufname: string): string +---@field supports_xfer nil|table +---@field render_action nil|fun(action: oil.Action): string +---@field perform_action nil|fun(action: oil.Action, cb: fun(err: nil|string)) + +---Get the entry on a specific line (1-indexed) +---@param bufnr integer +---@param lnum integer +---@return nil|oil.Entry +M.get_entry_on_line = function(bufnr, lnum) + local columns = require("oil.columns") + local config = require("oil.config") + local parser = require("oil.mutator.parser") + local util = require("oil.util") + if vim.bo[bufnr].filetype ~= "oil" then + return nil + end + local bufname = vim.api.nvim_buf_get_name(0) + local scheme = util.parse_url(bufname) + local adapter = config.get_adapter_by_scheme(scheme) + if not adapter then + return nil + end + + local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1] + local column_defs = columns.get_supported_columns(scheme) + local parsed_entry, entry = parser.parse_line(adapter, line, column_defs) + if parsed_entry then + if entry then + return util.export_entry(entry) + else + return { + name = parsed_entry.name, + type = parsed_entry._type, + } + end + end + -- This is a NEW entry that hasn't been saved yet + local name = vim.trim(line) + local entry_type + if vim.endswith(name, "/") then + name = name:sub(1, name:len() - 1) + entry_type = "directory" + else + entry_type = "file" + end + if name == "" then + return nil + else + return { + name = name, + type = entry_type, + } + end +end + +---Get the entry currently under the cursor +---@return nil|oil.Entry +M.get_cursor_entry = function() + local lnum = vim.api.nvim_win_get_cursor(0)[1] + return M.get_entry_on_line(0, lnum) +end + +---Discard all changes made to oil buffers +M.discard_all_changes = function() + local view = require("oil.view") + for _, bufnr in ipairs(view.get_all_buffers()) do + if vim.bo[bufnr].modified then + view.render_buffer_async(bufnr, {}, function(err) + if err then + vim.notify( + string.format( + "Error rendering oil buffer %s: %s", + vim.api.nvim_buf_get_name(bufnr), + err + ), + vim.log.levels.ERROR + ) + end + end) + end + end +end + +---Delete all files in the trash directory +---@private +---@note +--- Trash functionality is incomplete and experimental. +M.empty_trash = function() + local config = require("oil.config") + local fs = require("oil.fs") + local util = require("oil.util") + local trash_url = config.get_trash_url() + if not trash_url then + vim.notify("No trash directory configured", vim.log.levels.WARN) + return + end + local _, path = util.parse_url(trash_url) + local dir = fs.posix_to_os_path(path) + if vim.fn.isdirectory(dir) == 1 then + fs.recursive_delete("directory", dir, function(err) + if err then + vim.notify(string.format("Error emptying trash: %s", err), vim.log.levels.ERROR) + else + vim.notify("Trash emptied") + fs.mkdirp(dir) + end + end) + end +end + +---Change the display columns for oil +---@param cols oil.ColumnSpec[] +M.set_columns = function(cols) + require("oil.view").set_columns(cols) +end + +---Get the current directory +---@return nil|string +M.get_current_dir = function() + local config = require("oil.config") + local fs = require("oil.fs") + local util = require("oil.util") + local scheme, path = util.parse_url(vim.api.nvim_buf_get_name(0)) + if config.adapters[scheme] == "files" then + return fs.posix_to_os_path(path) + end +end + +---Get the oil url for a given directory +---@private +---@param dir nil|string When nil, use the cwd +---@return nil|string The parent url +---@return nil|string The basename (if present) of the file/dir we were just in +M.get_url_for_path = function(dir) + local config = require("oil.config") + local fs = require("oil.fs") + if dir then + local abspath = vim.fn.fnamemodify(dir, ":p") + local path = fs.os_to_posix_path(abspath) + return config.adapters.files .. path + else + local bufname = vim.api.nvim_buf_get_name(0) + return M.get_buffer_parent_url(bufname) + end +end + +---@private +---@param bufname string +---@return string +---@return nil|string +M.get_buffer_parent_url = function(bufname) + local config = require("oil.config") + local fs = require("oil.fs") + local pathutil = require("oil.pathutil") + local util = require("oil.util") + local scheme, path = util.parse_url(bufname) + if not scheme then + local parent, basename + scheme = config.adapters.files + if bufname == "" then + parent = fs.os_to_posix_path(vim.fn.getcwd()) + else + parent = fs.os_to_posix_path(vim.fn.fnamemodify(bufname, ":p:h")) + basename = vim.fn.fnamemodify(bufname, ":t") + end + local parent_url = util.addslash(scheme .. parent) + return parent_url, basename + else + scheme = config.remap_schemes[scheme] or scheme + local adapter = config.get_adapter_by_scheme(scheme) + local parent_url + if adapter.get_parent then + local adapter_scheme = config.adapters[adapter.name] + parent_url = adapter.get_parent(adapter_scheme .. path) + else + local parent = pathutil.parent(path) + parent_url = scheme .. util.addslash(parent) + end + if parent_url == bufname then + return parent_url + else + return util.addslash(parent_url), pathutil.basename(path) + end + end +end + +---Open oil browser in a floating window +---@param dir nil|string When nil, open the parent of the current buffer, or the cwd +M.open_float = function(dir) + local config = require("oil.config") + local util = require("oil.util") + local view = require("oil.view") + local parent_url, basename = M.get_url_for_path(dir) + if basename then + view.set_last_cursor(parent_url, basename) + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].bufhidden = "wipe" + local total_width = vim.o.columns + local total_height = util.get_editor_height() + local width = total_width - 2 * config.float.padding + if config.float.max_width > 0 then + width = math.min(width, config.float.max_width) + end + local height = total_height - 2 * config.float.padding + if config.float.max_height > 0 then + height = math.min(height, config.float.max_height) + end + local row = math.floor((total_width - width) / 2) + local col = math.floor((total_height - height) / 2) + local winid = vim.api.nvim_open_win(bufnr, true, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = config.float.border, + zindex = 45, + }) + for k, v in pairs(config.float.win_options) do + vim.api.nvim_win_set_option(winid, k, v) + end + util.add_title_to_win(winid, parent_url) + vim.cmd.edit({ args = { parent_url }, mods = { keepalt = true } }) +end + +---Open oil browser for a directory +---@param dir nil|string When nil, open the parent of the current buffer, or the cwd +M.open = function(dir) + local view = require("oil.view") + local parent_url, basename = M.get_url_for_path(dir) + if not parent_url then + return + end + if basename then + view.set_last_cursor(parent_url, basename) + end + if not pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer") then + vim.api.nvim_win_set_var(0, "oil_original_buffer", vim.api.nvim_get_current_buf()) + end + vim.cmd.edit({ args = { parent_url }, mods = { keepalt = true } }) +end + +---Restore the buffer that was present when oil was opened +M.close = function() + local util = require("oil.util") + if util.is_floating_win(0) then + vim.api.nvim_win_close(0, true) + return + end + local ok, bufnr = pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer") + if ok then + vim.api.nvim_win_del_var(0, "oil_original_buffer") + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_win_set_buf(0, bufnr) + return + end + end + vim.api.nvim_buf_delete(0, { force = true }) +end + +---Select the entry under the cursor +---@param opts table +--- vertical boolean Open the buffer in a vertical split +--- horizontal boolean Open the buffer in a horizontal split +--- split "aboveleft"|"belowright"|"topleft"|"botright" Split modifier +--- preview boolean Open the buffer in a preview window +M.select = function(opts) + local cache = require("oil.cache") + opts = vim.tbl_extend("keep", opts or {}, {}) + if opts.horizontal or opts.vertical or opts.preview then + opts.split = opts.split or "belowright" + end + if opts.preview and not opts.horizontal and opts.vertical == nil then + opts.vertical = true + end + local util = require("oil.util") + if util.is_floating_win() and opts.preview then + vim.notify("oil preview doesn't work in a floating window", vim.log.levels.ERROR) + return + end + local adapter = util.get_adapter(0) + if not adapter then + return + end + + local mode = vim.api.nvim_get_mode().mode + local is_visual = mode:match("^[vV]") + + local entries = {} + if is_visual then + -- This is the best way to get the visual selection at the moment + -- https://github.com/neovim/neovim/pull/13896 + local _, start_lnum, _, _ = unpack(vim.fn.getpos("v")) + local _, end_lnum, _, _, _ = unpack(vim.fn.getcurpos()) + if start_lnum > end_lnum then + start_lnum, end_lnum = end_lnum, start_lnum + end + for i = start_lnum, end_lnum do + local entry = M.get_entry_on_line(0, i) + if entry then + table.insert(entries, entry) + end + end + else + local entry = M.get_cursor_entry() + if entry then + table.insert(entries, entry) + end + end + if vim.tbl_isempty(entries) then + vim.notify("Could not find entry under cursor", vim.log.levels.ERROR) + return + end + if #entries > 1 and opts.preview then + vim.notify("Cannot preview multiple entries", vim.log.levels.WARN) + entries = { entries[1] } + end + -- Close the preview window + for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_option(winid, "previewwindow") then + vim.api.nvim_win_close(winid, true) + end + end + local bufname = vim.api.nvim_buf_get_name(0) + local prev_win = vim.api.nvim_get_current_win() + for _, entry in ipairs(entries) do + local scheme, dir = util.parse_url(bufname) + local child = dir .. entry.name + local url = scheme .. child + local buffer_name + if + entry.type == "directory" + or ( + entry.type == "link" + and entry.meta + and entry.meta.link_stat + and entry.meta.link_stat.type == "directory" + ) + then + buffer_name = util.addslash(url) + -- If this is a new directory BUT we think we already have an entry with this name, disallow + -- entry. This prevents the case of MOVE /foo -> /bar + CREATE /foo. + -- If you enter the new /foo, it will show the contents of the old /foo. + if not entry.id and cache.list_url(bufname)[entry.name] then + vim.notify("Please save changes before entering new directory", vim.log.levels.ERROR) + return + end + else + if util.is_floating_win() then + vim.api.nvim_win_close(0, false) + end + buffer_name = adapter.url_to_buffer_name(url) + end + local mods = { + vertical = opts.vertical, + horizontal = opts.horizontal, + split = opts.split, + keepalt = true, + } + local cmd = opts.split and "split" or "edit" + vim.cmd({ + cmd = cmd, + args = { buffer_name }, + mods = mods, + }) + if opts.preview then + vim.api.nvim_win_set_option(0, "previewwindow", true) + vim.api.nvim_set_current_win(prev_win) + end + -- Set opts.split so that for every entry after the first, we do a split + opts.split = opts.split or "belowright" + if not opts.horizontal and opts.vertical == nil then + opts.vertical = true + end + end +end + +---@param bufnr integer +local function maybe_hijack_directory_buffer(bufnr) + local config = require("oil.config") + local util = require("oil.util") + local bufname = vim.api.nvim_buf_get_name(bufnr) + if bufname == "" then + return + end + if util.parse_url(bufname) or vim.fn.isdirectory(bufname) == 0 then + return + end + util.rename_buffer( + bufnr, + util.addslash(config.adapters.files .. vim.fn.fnamemodify(bufname, ":p")) + ) +end + +---@private +M._get_highlights = function() + return { + { + name = "OilDir", + link = "Special", + desc = "Directories in an oil buffer", + }, + { + name = "OilSocket", + link = "Keyword", + desc = "Socket files in an oil buffer", + }, + { + name = "OilLink", + link = nil, + desc = "Soft links in an oil buffer", + }, + { + name = "OilFile", + link = nil, + desc = "Normal files in an oil buffer", + }, + { + name = "OilCreate", + link = "DiagnosticInfo", + desc = "Create action in the oil preview window", + }, + { + name = "OilDelete", + link = "DiagnosticError", + desc = "Delete action in the oil preview window", + }, + { + name = "OilMove", + link = "DiagnosticWarn", + desc = "Move action in the oil preview window", + }, + { + name = "OilCopy", + link = "DiagnosticHint", + desc = "Copy action in the oil preview window", + }, + { + name = "OilChange", + link = "Special", + desc = "Change action in the oil preview window", + }, + } +end + +local function set_colors() + for _, conf in ipairs(M._get_highlights()) do + if conf.link then + vim.api.nvim_set_hl(0, conf.name, { default = true, link = conf.link }) + end + end + if not pcall(vim.api.nvim_get_hl_by_name, "FloatTitle") then + local border = vim.api.nvim_get_hl_by_name("FloatBorder", true) + local normal = vim.api.nvim_get_hl_by_name("Normal", true) + vim.api.nvim_set_hl( + 0, + "FloatTitle", + { fg = normal.foreground, bg = border.background or normal.background } + ) + end +end + +---Save all changes +---@param opts nil|table +--- confirm nil|boolean Show confirmation when true, never when false, respect skip_confirm_for_simple_edits if nil +M.save = function(opts) + opts = opts or {} + local mutator = require("oil.mutator") + mutator.try_write_changes(opts.confirm) +end + +---Initialize oil +---@param opts nil|table +M.setup = function(opts) + local config = require("oil.config") + config.setup(opts) + set_colors() + local aug = vim.api.nvim_create_augroup("Oil", {}) + if vim.fn.exists("#FileExplorer") then + vim.api.nvim_create_augroup("FileExplorer", { clear = true }) + end + + local patterns = {} + for scheme in pairs(config.adapters) do + -- We added a reverse lookup to config.adapters, so filter the keys + if vim.endswith(scheme, "://") then + table.insert(patterns, scheme .. "*") + end + end + local scheme_pattern = table.concat(patterns, ",") + + vim.api.nvim_create_autocmd("ColorScheme", { + desc = "Set default oil highlights", + group = aug, + pattern = "*", + callback = set_colors, + }) + vim.api.nvim_create_autocmd("BufReadCmd", { + group = aug, + pattern = scheme_pattern, + nested = true, + callback = function(params) + local loading = require("oil.loading") + local util = require("oil.util") + local view = require("oil.view") + local adapter = config.get_adapter_by_scheme(params.file) + local bufnr = params.buf + + loading.set_loading(bufnr, true) + local function finish(new_url) + if new_url ~= params.file then + util.rename_buffer(bufnr, new_url) + end + vim.cmd.doautocmd({ args = { "BufReadPre", params.file }, mods = { silent = true } }) + view.initialize(bufnr) + vim.cmd.doautocmd({ args = { "BufReadPost", params.file }, mods = { silent = true } }) + end + + if adapter.normalize_url then + adapter.normalize_url(params.file, finish) + else + finish(util.addslash(params.file)) + end + end, + }) + vim.api.nvim_create_autocmd("BufWriteCmd", { + group = aug, + pattern = scheme_pattern, + nested = true, + callback = function(params) + vim.cmd.doautocmd({ args = { "BufWritePre", params.file }, mods = { silent = true } }) + M.save() + vim.bo[params.buf].modified = false + vim.cmd.doautocmd({ args = { "BufWritePost", params.file }, mods = { silent = true } }) + end, + }) + vim.api.nvim_create_autocmd("BufWinEnter", { + desc = "Set/unset oil window options", + pattern = "*", + callback = function() + local view = require("oil.view") + if vim.bo.filetype == "oil" then + view.set_win_options() + elseif config.restore_win_options then + view.restore_win_options() + end + end, + }) + vim.api.nvim_create_autocmd("BufAdd", { + group = aug, + pattern = "*", + nested = true, + callback = function(params) + maybe_hijack_directory_buffer(params.buf) + end, + }) + maybe_hijack_directory_buffer(0) +end + +return M diff --git a/lua/oil/keymap_util.lua b/lua/oil/keymap_util.lua new file mode 100644 index 0000000..0a6d5bb --- /dev/null +++ b/lua/oil/keymap_util.lua @@ -0,0 +1,100 @@ +local actions = require("oil.actions") +local util = require("oil.util") +local M = {} + +local function resolve(rhs) + if type(rhs) == "string" and vim.startswith(rhs, "actions.") then + return resolve(actions[vim.split(rhs, ".", true)[2]]) + elseif type(rhs) == "table" then + local opts = vim.deepcopy(rhs) + opts.callback = nil + return rhs.callback, opts + end + return rhs, {} +end + +M.set_keymaps = function(mode, keymaps, bufnr) + for k, v in pairs(keymaps) do + local rhs, opts = resolve(v) + if rhs then + vim.keymap.set(mode, k, rhs, vim.tbl_extend("keep", { buffer = bufnr }, opts)) + end + end +end + +M.show_help = function(keymaps) + local rhs_to_lhs = {} + local lhs_to_all_lhs = {} + for k, rhs in pairs(keymaps) do + if rhs then + if rhs_to_lhs[rhs] then + local first_lhs = rhs_to_lhs[rhs] + table.insert(lhs_to_all_lhs[first_lhs], k) + else + rhs_to_lhs[rhs] = k + lhs_to_all_lhs[k] = { k } + end + end + end + + local col_left = {} + local col_desc = {} + local max_lhs = 1 + for k, rhs in pairs(keymaps) do + local all_lhs = lhs_to_all_lhs[k] + if all_lhs then + local _, opts = resolve(rhs) + local keystr = table.concat(all_lhs, "/") + max_lhs = math.max(max_lhs, vim.api.nvim_strwidth(keystr)) + table.insert(col_left, { str = keystr, all_lhs = all_lhs }) + table.insert(col_desc, opts.desc or "") + end + end + + local lines = {} + local highlights = {} + local max_line = 1 + for i = 1, #col_left do + local left = col_left[i] + local desc = col_desc[i] + local line = string.format(" %s %s", util.rpad(left.str, max_lhs), desc) + max_line = math.max(max_line, vim.api.nvim_strwidth(line)) + table.insert(lines, line) + local start = 1 + for _, key in ipairs(left.all_lhs) do + local keywidth = vim.api.nvim_strwidth(key) + table.insert(highlights, { "Special", #lines, start, start + keywidth }) + start = start + keywidth + 1 + end + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) + local ns = vim.api.nvim_create_namespace("Oil") + for _, hl in ipairs(highlights) do + local hl_group, lnum, start_col, end_col = unpack(hl) + vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, start_col, { + end_col = end_col, + hl_group = hl_group, + }) + end + vim.keymap.set("n", "q", "close", { buffer = bufnr }) + vim.keymap.set("n", "", "close", { buffer = bufnr }) + vim.api.nvim_buf_set_option(bufnr, "modifiable", false) + vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") + + local editor_width = vim.o.columns + local editor_height = util.get_editor_height() + vim.api.nvim_open_win(bufnr, true, { + relative = "editor", + row = math.max(0, (editor_height - #lines) / 2), + col = math.max(0, (editor_width - max_line - 1) / 2), + width = math.min(editor_width, max_line + 1), + height = math.min(editor_height, #lines), + zindex = 150, + style = "minimal", + border = "rounded", + }) +end + +return M diff --git a/lua/oil/loading.lua b/lua/oil/loading.lua new file mode 100644 index 0000000..639dc9c --- /dev/null +++ b/lua/oil/loading.lua @@ -0,0 +1,61 @@ +local util = require("oil.util") +local M = {} + +local timers = {} + +local FPS = 20 + +M.is_loading = function(bufnr) + return timers[bufnr] ~= nil +end + +M.get_bar_iter = function(opts) + opts = vim.tbl_deep_extend("keep", opts or {}, { + bar_size = 3, + width = 20, + }) + local i = 0 + return function() + local chars = { "[" } + for _ = 1, opts.width - 2 do + table.insert(chars, " ") + end + table.insert(chars, "]") + + for j = i - opts.bar_size, i do + if j > 1 and j < opts.width then + chars[j] = "=" + end + end + + i = (i + 1) % (opts.width + opts.bar_size) + return table.concat(chars, "") + end +end + +M.set_loading = function(bufnr, is_loading) + if is_loading then + if timers[bufnr] == nil then + local width = 20 + timers[bufnr] = vim.loop.new_timer() + local bar_iter = M.get_bar_iter({ width = width }) + timers[bufnr]:start( + 100, -- Delay the loading screen just a bit to avoid flicker + math.floor(1000 / FPS), + vim.schedule_wrap(function() + if not vim.api.nvim_buf_is_valid(bufnr) or not timers[bufnr] then + M.set_loading(bufnr, false) + return + end + local lines = { util.lpad("Loading", math.floor(width / 2) - 3), bar_iter() } + util.render_centered_text(bufnr, lines) + end) + ) + end + elseif timers[bufnr] then + timers[bufnr]:close() + timers[bufnr] = nil + end +end + +return M diff --git a/lua/oil/mutator/disclaimer.lua b/lua/oil/mutator/disclaimer.lua new file mode 100644 index 0000000..45255cc --- /dev/null +++ b/lua/oil/mutator/disclaimer.lua @@ -0,0 +1,71 @@ +local fs = require("oil.fs") +local ReplLayout = require("oil.repl_layout") +local M = {} + +M.show = function(callback) + local marker_file = fs.join(vim.fn.stdpath("cache"), ".oil_accepted_disclaimer") + vim.loop.fs_stat( + marker_file, + vim.schedule_wrap(function(err, stat) + if stat and stat.type and not err then + callback(true) + return + end + + local confirmation = "I understand this may destroy my files" + local lines = { + "WARNING", + "This plugin has been tested thoroughly, but it is still new.", + "There is a chance that there may be bugs that could lead to data loss.", + "I recommend that you ONLY use it for files that are checked in to version control.", + "", + string.format('Please type: "%s" below', confirmation), + "", + } + local hints = { + "Try again", + "Not quite!", + "It's right there ^^^^^^^^^^^", + "...seriously?", + "Just type this ^^^^", + } + local attempt = 0 + local repl + repl = ReplLayout.new({ + lines = lines, + on_submit = function(line) + if line:upper() ~= confirmation:upper() then + attempt = attempt % #hints + 1 + vim.api.nvim_buf_set_lines(repl.input_bufnr, 0, -1, true, {}) + vim.bo[repl.view_bufnr].modifiable = true + vim.api.nvim_buf_set_lines(repl.view_bufnr, 6, 7, true, { hints[attempt] }) + vim.bo[repl.view_bufnr].modifiable = false + vim.bo[repl.view_bufnr].modified = false + else + fs.mkdirp(vim.fn.fnamemodify(marker_file, ":h")) + fs.touch( + marker_file, + vim.schedule_wrap(function(err2) + if err2 then + vim.notify( + string.format("Error recording response: %s", err2), + vim.log.levels.WARN + ) + end + callback(true) + repl:close() + end) + ) + end + end, + on_cancel = function() + callback(false) + end, + }) + local ns = vim.api.nvim_create_namespace("Oil") + vim.api.nvim_buf_add_highlight(repl.view_bufnr, ns, "DiagnosticError", 0, 0, -1) + end) + ) +end + +return M diff --git a/lua/oil/mutator/init.lua b/lua/oil/mutator/init.lua new file mode 100644 index 0000000..2bb0be2 --- /dev/null +++ b/lua/oil/mutator/init.lua @@ -0,0 +1,507 @@ +local cache = require("oil.cache") +local columns = require("oil.columns") +local config = require("oil.config") +local disclaimer = require("oil.mutator.disclaimer") +local oil = require("oil") +local parser = require("oil.mutator.parser") +local pathutil = require("oil.pathutil") +local preview = require("oil.mutator.preview") +local Progress = require("oil.mutator.progress") +local Trie = require("oil.mutator.trie") +local util = require("oil.util") +local view = require("oil.view") +local FIELD = require("oil.constants").FIELD +local M = {} + +---@alias oil.Action oil.CreateAction|oil.DeleteAction|oil.MoveAction|oil.CopyAction|oil.ChangeAction + +---@class oil.CreateAction +---@field type "create" +---@field url string +---@field entry_type oil.EntryType +---@field link nil|string + +---@class oil.DeleteAction +---@field type "delete" +---@field url string +---@field entry_type oil.EntryType + +---@class oil.MoveAction +---@field type "move" +---@field entry_type oil.EntryType +---@field src_url string +---@field dest_url string + +---@class oil.CopyAction +---@field type "copy" +---@field entry_type oil.EntryType +---@field src_url string +---@field dest_url string + +---@class oil.ChangeAction +---@field type "change" +---@field entry_type oil.EntryType +---@field url string +---@field column string +---@field value any + +---@param all_diffs table +---@return oil.Action[] +M.create_actions_from_diffs = function(all_diffs) + ---@type oil.Action[] + local actions = {} + + local diff_by_id = setmetatable({}, { + __index = function(t, key) + local list = {} + rawset(t, key, list) + return list + end, + }) + for bufnr, diffs in pairs(all_diffs) do + local adapter = util.get_adapter(bufnr) + if not adapter then + error("Missing adapter") + end + local parent_url = vim.api.nvim_buf_get_name(bufnr) + for _, diff in ipairs(diffs) do + if diff.type == "new" then + if diff.id then + local by_id = diff_by_id[diff.id] + -- FIXME this is kind of a hack. We shouldn't be setting undocumented fields on the diff + diff.dest = parent_url .. diff.name + table.insert(by_id, diff) + else + -- Parse nested files like foo/bar/baz + local pieces = vim.split(diff.name, "/") + local url = parent_url:gsub("/$", "") + for i, v in ipairs(pieces) do + local is_last = i == #pieces + local entry_type = is_last and diff.entry_type or "directory" + local alternation = v:match("{([^}]+)}") + if is_last and alternation then + -- Parse alternations like foo.{js,test.js} + for _, alt in ipairs(vim.split(alternation, ",")) do + local alt_url = url .. "/" .. v:gsub("{[^}]+}", alt) + table.insert(actions, { + type = "create", + url = alt_url, + entry_type = entry_type, + link = diff.link, + }) + end + else + url = url .. "/" .. v + table.insert(actions, { + type = "create", + url = url, + entry_type = entry_type, + link = diff.link, + }) + end + end + end + elseif diff.type == "change" then + table.insert(actions, { + type = "change", + url = parent_url .. diff.name, + entry_type = diff.entry_type, + column = diff.column, + value = diff.value, + }) + else + local by_id = diff_by_id[diff.id] + by_id.has_delete = true + -- Don't insert the delete. We already know that there is a delete because of the presense + -- in the diff_by_id map. The list will only include the 'new' diffs. + end + end + end + + for id, diffs in pairs(diff_by_id) do + local entry = cache.get_entry_by_id(id) + if not entry then + error(string.format("Could not find entry %d", id)) + end + if diffs.has_delete then + local has_create = #diffs > 0 + if has_create then + -- MOVE (+ optional copies) when has both creates and delete + for i, diff in ipairs(diffs) do + table.insert(actions, { + type = i == #diffs and "move" or "copy", + entry_type = entry[FIELD.type], + dest_url = diff.dest, + src_url = cache.get_parent_url(id) .. entry[FIELD.name], + }) + end + else + -- DELETE when no create + table.insert(actions, { + type = "delete", + entry_type = entry[FIELD.type], + url = cache.get_parent_url(id) .. entry[FIELD.name], + }) + end + else + -- COPY when create but no delete + for _, diff in ipairs(diffs) do + table.insert(actions, { + type = "copy", + entry_type = entry[FIELD.type], + src_url = cache.get_parent_url(id) .. entry[FIELD.name], + dest_url = diff.dest, + }) + end + end + end + + return M.enforce_action_order(actions) +end + +---@param actions oil.Action[] +---@return oil.Action[] +M.enforce_action_order = function(actions) + local src_trie = Trie.new() + local dest_trie = Trie.new() + for _, action in ipairs(actions) do + if action.type == "delete" or action.type == "change" then + src_trie:insert_action(action.url, action) + elseif action.type == "create" then + dest_trie:insert_action(action.url, action) + else + dest_trie:insert_action(action.dest_url, action) + src_trie:insert_action(action.src_url, action) + end + end + + -- 1. create a graph, each node points to all of its dependencies + -- 2. for each action, if not added, find it in the graph + -- 3. traverse through the graph until you reach a node that has no dependencies (leaf) + -- 4. append that action to the return value, and remove it from the graph + -- a. TODO optimization: check immediate parents to see if they have no dependencies now + -- 5. repeat + + -- Gets the dependencies of a particular action. Effectively dynamically calculates the dependency + -- "edges" of the graph. + local function get_deps(action) + local ret = {} + if action.type == "delete" then + return ret + elseif action.type == "create" then + -- Finish operating on parents first + -- e.g. NEW /a BEFORE NEW /a/b + dest_trie:accum_first_parents_of(action.url, ret) + -- Process remove path before creating new path + -- e.g. DELETE /a BEFORE NEW /a + src_trie:accum_actions_at(action.url, ret, function(a) + return a.type == "move" or a.type == "delete" + end) + elseif action.type == "change" then + -- Finish operating on parents first + -- e.g. NEW /a BEFORE CHANGE /a/b + dest_trie:accum_first_parents_of(action.url, ret) + -- Finish operations on this path first + -- e.g. NEW /a BEFORE CHANGE /a + dest_trie:accum_actions_at(action.url, ret) + -- Finish copy from operations first + -- e.g. COPY /a -> /b BEFORE CHANGE /a + src_trie:accum_actions_at(action.url, ret, function(entry) + return entry.type == "copy" + end) + elseif action.type == "move" then + -- Finish operating on parents first + -- e.g. NEW /a BEFORE MOVE /z -> /a/b + dest_trie:accum_first_parents_of(action.dest_url, ret) + -- Process children before moving + -- e.g. NEW /a/b BEFORE MOVE /a -> /b + dest_trie:accum_children_of(action.src_url, ret) + -- Copy children before moving parent dir + -- e.g. COPY /a/b -> /b BEFORE MOVE /a -> /d + src_trie:accum_children_of(action.src_url, ret, function(a) + return a.type == "copy" + end) + -- Process remove path before moving to new path + -- e.g. MOVE /a -> /b BEFORE MOVE /c -> /a + src_trie:accum_actions_at(action.dest_url, ret, function(a) + return a.type == "move" or a.type == "delete" + end) + elseif action.type == "copy" then + -- Finish operating on parents first + -- e.g. NEW /a BEFORE COPY /z -> /a/b + dest_trie:accum_first_parents_of(action.dest_url, ret) + -- Process children before copying + -- e.g. NEW /a/b BEFORE COPY /a -> /b + dest_trie:accum_children_of(action.src_url, ret) + -- Process remove path before copying to new path + -- e.g. MOVE /a -> /b BEFORE COPY /c -> /a + src_trie:accum_actions_at(action.dest_url, ret, function(a) + return a.type == "move" or a.type == "delete" + end) + end + return ret + end + + ---@return nil|oil.Action The leaf action + ---@return nil|oil.Action When no leaves found, this is the last action in the loop + local function find_leaf(action, seen) + if not seen then + seen = {} + elseif seen[action] then + return nil, action + end + seen[action] = true + local deps = get_deps(action) + if vim.tbl_isempty(deps) then + return action + end + local action_in_loop + for _, dep in ipairs(deps) do + local leaf, loop_action = find_leaf(dep, seen) + if leaf then + return leaf + elseif not action_in_loop and loop_action then + action_in_loop = loop_action + end + end + return nil, action_in_loop + end + + local ret = {} + local after = {} + while not vim.tbl_isempty(actions) do + local action = actions[1] + local selected, loop_action = find_leaf(action) + local to_remove + if selected then + to_remove = selected + else + if loop_action and loop_action.type == "move" then + -- If this is moving a parent into itself, that's an error + if vim.startswith(loop_action.dest_url, loop_action.src_url) then + error("Detected cycle in desired paths") + end + + -- We've detected a move cycle (e.g. MOVE /a -> /b + MOVE /b -> /a) + -- Split one of the moves and retry + local intermediate_url = + string.format("%s__oil_tmp_%05d", loop_action.src_url, math.random(999999)) + local move_1 = { + type = "move", + entry_type = loop_action.entry_type, + src_url = loop_action.src_url, + dest_url = intermediate_url, + } + local move_2 = { + type = "move", + entry_type = loop_action.entry_type, + src_url = intermediate_url, + dest_url = loop_action.dest_url, + } + to_remove = loop_action + table.insert(actions, move_1) + table.insert(after, move_2) + dest_trie:insert_action(move_1.dest_url, move_1) + src_trie:insert_action(move_1.src_url, move_1) + else + error("Detected cycle in desired paths") + end + end + + if selected then + if selected.type == "move" or selected.type == "copy" then + if vim.startswith(selected.dest_url, selected.src_url .. "/") then + error( + string.format( + "Cannot move or copy parent into itself: %s -> %s", + selected.src_url, + selected.dest_url + ) + ) + end + end + table.insert(ret, selected) + end + + if to_remove then + if to_remove.type == "delete" or to_remove.type == "change" then + src_trie:remove_action(to_remove.url, to_remove) + elseif to_remove.type == "create" then + dest_trie:remove_action(to_remove.url, to_remove) + else + dest_trie:remove_action(to_remove.dest_url, to_remove) + src_trie:remove_action(to_remove.src_url, to_remove) + end + for i, a in ipairs(actions) do + if a == to_remove then + table.remove(actions, i) + break + end + end + end + end + + vim.list_extend(ret, after) + return ret +end + +---@param actions oil.Action[] +---@param cb fun(err: nil|string) +M.process_actions = function(actions, cb) + -- convert delete actions to move-to-trash + local trash_url = config.get_trash_url() + if trash_url then + for i, v in ipairs(actions) do + if v.type == "delete" then + local scheme, path = util.parse_url(v.url) + if config.adapters[scheme] == "files" then + actions[i] = { + type = "move", + src_url = v.url, + entry_type = v.entry_type, + dest_url = trash_url .. "/" .. pathutil.basename(path) .. string.format( + "_%06d", + math.random(999999) + ), + } + end + end + end + end + + -- Convert cross-adapter moves to a copy + delete + for _, action in ipairs(actions) do + if action.type == "move" then + local src_scheme = util.parse_url(action.src_url) + local dest_scheme = util.parse_url(action.dest_url) + if src_scheme ~= dest_scheme then + action.type = "copy" + table.insert(actions, { + type = "delete", + url = action.src_url, + entry_type = action.entry_type, + }) + end + end + end + + local finished = false + local progress = Progress.new() + -- Defer showing the progress to avoid flicker for fast operations + vim.defer_fn(function() + if not finished then + progress:show() + end + end, 100) + + local function finish(...) + finished = true + progress:close() + cb(...) + end + + local idx = 1 + local next_action + next_action = function() + if idx > #actions then + finish() + return + end + local action = actions[idx] + progress:set_action(action, idx, #actions) + idx = idx + 1 + local ok, adapter = pcall(util.get_adapter_for_action, action) + if not ok then + return finish(adapter) + end + local callback = vim.schedule_wrap(function(err) + if err then + finish(err) + else + cache.perform_action(action) + next_action() + end + end) + if action.type == "change" then + columns.perform_change_action(adapter, action, callback) + else + adapter.perform_action(action, callback) + end + end + next_action() +end + +---@param confirm nil|boolean +M.try_write_changes = function(confirm) + local buffers = view.get_all_buffers() + local all_diffs = {} + local all_errors = {} + for _, bufnr in ipairs(buffers) do + -- Lock the buffer to prevent race conditions + vim.bo[bufnr].modifiable = false + if vim.bo[bufnr].modified then + local diffs, errors = parser.parse(bufnr) + all_diffs[bufnr] = diffs + if not vim.tbl_isempty(errors) then + all_errors[bufnr] = errors + end + end + end + + local ns = vim.api.nvim_create_namespace("Oil") + vim.diagnostic.reset(ns) + if not vim.tbl_isempty(all_errors) then + vim.notify("Error parsing oil buffers", vim.log.levels.ERROR) + for bufnr, errors in pairs(all_errors) do + vim.diagnostic.set(ns, bufnr, errors) + end + + -- Jump to an error + local curbuf = vim.api.nvim_get_current_buf() + if all_errors[curbuf] then + pcall( + vim.api.nvim_win_set_cursor, + 0, + { all_errors[curbuf][1].lnum + 1, all_errors[curbuf][1].col } + ) + else + local bufnr, errs = next(pairs(all_errors)) + vim.api.nvim_win_set_buf(0, bufnr) + pcall(vim.api.nvim_win_set_cursor, 0, { errs[1].lnum + 1, errs[1].col }) + end + return + end + + local actions = M.create_actions_from_diffs(all_diffs) + disclaimer.show(function(disclaimed) + if not disclaimed then + return + end + preview.show(actions, confirm, function(proceed) + if not proceed then + return + end + + M.process_actions( + actions, + vim.schedule_wrap(function(err) + if err then + vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR) + else + local current_entry = oil.get_cursor_entry() + if current_entry then + -- get the entry under the cursor and make sure the cursor stays on it + view.set_last_cursor( + vim.api.nvim_buf_get_name(0), + vim.split(current_entry.name, "/")[1] + ) + end + view.rerender_visible_and_cleanup({ preserve_undo = M.trash }) + end + end) + ) + end) + end) +end + +return M diff --git a/lua/oil/mutator/parser.lua b/lua/oil/mutator/parser.lua new file mode 100644 index 0000000..5edddb7 --- /dev/null +++ b/lua/oil/mutator/parser.lua @@ -0,0 +1,225 @@ +local cache = require("oil.cache") +local columns = require("oil.columns") +local util = require("oil.util") +local view = require("oil.view") +local FIELD = require("oil.constants").FIELD +local M = {} + +---@alias oil.Diff oil.DiffNew|oil.DiffDelete|oil.DiffChange + +---@class oil.DiffNew +---@field type "new" +---@field name string +---@field entry_type oil.EntryType +---@field id nil|integer +---@field link nil|string + +---@class oil.DiffDelete +---@field type "delete" +---@field name string +---@field id integer +--- +---@class oil.DiffChange +---@field type "change" +---@field entry_type oil.EntryType +---@field name string +---@field column string +---@field value any + +---@param name string +---@return string +---@return boolean +local function parsedir(name) + local isdir = vim.endswith(name, "/") + if isdir then + name = name:sub(1, name:len() - 1) + end + return name, isdir +end + +---Parse a single line in a buffer +---@param adapter oil.Adapter +---@param line string +---@param column_defs oil.ColumnSpec[] +---@return table +---@return nil|oil.InternalEntry +M.parse_line = function(adapter, line, column_defs) + local ret = {} + local value, rem = line:match("^/(%d+) (.+)$") + if not value then + return nil, nil, "Malformed ID at start of line" + end + ret.id = tonumber(value) + for _, def in ipairs(column_defs) do + local name = util.split_config(def) + value, rem = columns.parse_col(adapter, rem, def) + if not value then + return nil, nil, string.format("Parsing %s failed", name) + end + ret[name] = value + end + local name = rem + if name then + local isdir + name, isdir = parsedir(vim.trim(name)) + if name ~= "" then + ret.name = name + end + ret._type = isdir and "directory" or "file" + end + local entry = cache.get_entry_by_id(ret.id) + if not entry then + return ret + end + + -- Parse the symlink syntax + local meta = entry[FIELD.meta] + local entry_type = entry[FIELD.type] + if entry_type == "link" and meta and meta.link then + local name_pieces = vim.split(ret.name, " -> ", { plain = true }) + if #name_pieces ~= 2 then + ret.name = "" + return ret + end + ret.name = parsedir(vim.trim(name_pieces[1])) + ret.link_target = name_pieces[2] + ret._type = "link" + end + + -- Try to keep the same file type + if entry_type ~= "directory" and entry_type ~= "file" and ret._type ~= "directory" then + ret._type = entry[FIELD.type] + end + + return ret, entry +end + +---@param bufnr integer +---@return oil.Diff[] +---@return table[] Parsing errors +M.parse = function(bufnr) + local diffs = {} + local errors = {} + local bufname = vim.api.nvim_buf_get_name(bufnr) + local adapter = util.get_adapter(bufnr) + if not adapter then + table.insert(errors, { + lnum = 1, + col = 0, + message = string.format("Cannot parse buffer '%s': No adapter", bufname), + }) + return diffs, errors + end + local scheme, path = util.parse_url(bufname) + local column_defs = columns.get_supported_columns(scheme) + local parent_url = scheme .. path + local children = cache.list_url(parent_url) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + local original_entries = {} + for _, child in pairs(children) do + if view.should_display(child) then + original_entries[child[FIELD.name]] = child[FIELD.id] + end + end + for i, line in ipairs(lines) do + if line:match("^/%d+") then + local parsed_entry, entry, err = M.parse_line(adapter, line, column_defs) + if err then + table.insert(errors, { + message = err, + lnum = i - 1, + col = 0, + }) + goto continue + end + if not parsed_entry.name or parsed_entry.name:match("/") or not entry then + local message + if not parsed_entry.name then + message = "No filename found" + elseif not entry then + message = "Could not find existing entry (was the ID changed?)" + else + message = "Filename cannot contain '/'" + end + table.insert(errors, { + message = message, + lnum = i, + col = 0, + }) + goto continue + end + local meta = entry[FIELD.meta] + if original_entries[parsed_entry.name] == parsed_entry.id then + if entry[FIELD.type] == "link" and (not meta or meta.link ~= parsed_entry.link_target) then + table.insert(diffs, { + type = "new", + name = parsed_entry.name, + entry_type = "link", + link = parsed_entry.link_target, + }) + else + original_entries[parsed_entry.name] = nil + end + else + table.insert(diffs, { + type = "new", + name = parsed_entry.name, + entry_type = parsed_entry._type, + id = parsed_entry.id, + link = parsed_entry.link_target, + }) + end + + for _, col_def in ipairs(column_defs) do + local col_name = util.split_config(col_def) + if columns.compare(adapter, col_name, entry, parsed_entry[col_name]) then + table.insert(diffs, { + type = "change", + name = parsed_entry.name, + entry_type = entry[FIELD.type], + column = col_name, + value = parsed_entry[col_name], + }) + end + end + else + local name, isdir = parsedir(vim.trim(line)) + if vim.startswith(name, "/") then + table.insert(errors, { + message = "Paths cannot start with '/'", + lnum = i, + col = 0, + }) + goto continue + end + if name ~= "" then + local link_pieces = vim.split(name, " -> ", { plain = true }) + local entry_type = isdir and "directory" or "file" + local link + if #link_pieces == 2 then + entry_type = "link" + name, link = unpack(link_pieces) + end + table.insert(diffs, { + type = "new", + name = name, + entry_type = entry_type, + link = link, + }) + end + end + ::continue:: + end + + for name, child_id in pairs(original_entries) do + table.insert(diffs, { + type = "delete", + name = name, + id = child_id, + }) + end + + return diffs, errors +end + +return M diff --git a/lua/oil/mutator/preview.lua b/lua/oil/mutator/preview.lua new file mode 100644 index 0000000..82308c6 --- /dev/null +++ b/lua/oil/mutator/preview.lua @@ -0,0 +1,130 @@ +local columns = require("oil.columns") +local config = require("oil.config") +local util = require("oil.util") +local M = {} + +---@param actions oil.Action[] +---@return boolean +local function is_simple_edit(actions) + local num_create = 0 + local num_copy = 0 + local num_move = 0 + for _, action in ipairs(actions) do + -- If there are any deletes, it is not a simple edit + if action.type == "delete" then + return false + elseif action.type == "create" then + num_create = num_create + 1 + elseif action.type == "copy" then + num_copy = num_copy + 1 + -- Cross-adapter copies are not simple + if util.parse_url(action.src_url) ~= util.parse_url(action.dest_url) then + return false + end + elseif action.type == "move" then + num_move = num_move + 1 + -- Cross-adapter moves are not simple + if util.parse_url(action.src_url) ~= util.parse_url(action.dest_url) then + return false + end + end + end + -- More than one move/copy is complex + if num_move > 1 or num_copy > 1 then + return false + end + -- More than 5 creates is complex + if num_create > 5 then + return false + end + return true +end + +---@param actions oil.Action[] +---@param should_confirm nil|boolean +---@param cb fun(proceed: boolean) +M.show = vim.schedule_wrap(function(actions, should_confirm, cb) + if should_confirm == false then + cb(true) + return + end + if should_confirm == nil and config.skip_confirm_for_simple_edits and is_simple_edit(actions) then + cb(true) + return + end + -- The schedule wrap ensures that we actually enter the floating window. + -- Not sure why it doesn't work without that + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].bufhidden = "wipe" + local width = 120 + local height = 40 + local winid = vim.api.nvim_open_win(bufnr, true, { + relative = "editor", + width = width, + height = height, + row = math.floor((vim.o.lines - vim.o.cmdheight - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + style = "minimal", + border = "rounded", + }) + vim.bo[bufnr].syntax = "oil_preview" + + local lines = {} + for _, action in ipairs(actions) do + local adapter = util.get_adapter_for_action(action) + if action.type == "change" then + table.insert(lines, columns.render_change_action(adapter, action)) + else + table.insert(lines, adapter.render_action(action)) + end + end + table.insert(lines, "") + width = vim.api.nvim_win_get_width(0) + local last_line = "[O]k [C]ancel" + local highlights = {} + local padding = string.rep(" ", math.floor((width - last_line:len()) / 2)) + last_line = padding .. last_line + table.insert(highlights, { "Special", #lines, padding:len(), padding:len() + 3 }) + table.insert(highlights, { "Special", #lines, padding:len() + 8, padding:len() + 11 }) + table.insert(lines, last_line) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) + vim.bo[bufnr].modified = false + vim.bo[bufnr].modifiable = false + local ns = vim.api.nvim_create_namespace("Oil") + for _, hl in ipairs(highlights) do + vim.api.nvim_buf_add_highlight(bufnr, ns, unpack(hl)) + end + + local cancel + local confirm + local function make_callback(value) + return function() + confirm = function() end + cancel = function() end + vim.api.nvim_win_close(winid, true) + cb(value) + end + end + cancel = make_callback(false) + confirm = make_callback(true) + vim.api.nvim_create_autocmd("BufLeave", { + callback = cancel, + once = true, + nested = true, + buffer = bufnr, + }) + vim.api.nvim_create_autocmd("WinLeave", { + callback = cancel, + once = true, + nested = true, + }) + vim.keymap.set("n", "q", cancel, { buffer = bufnr }) + vim.keymap.set("n", "C", cancel, { buffer = bufnr }) + vim.keymap.set("n", "c", cancel, { buffer = bufnr }) + vim.keymap.set("n", "", cancel, { buffer = bufnr }) + vim.keymap.set("n", "", cancel, { buffer = bufnr }) + vim.keymap.set("n", "O", confirm, { buffer = bufnr }) + vim.keymap.set("n", "o", confirm, { buffer = bufnr }) +end) + +return M diff --git a/lua/oil/mutator/progress.lua b/lua/oil/mutator/progress.lua new file mode 100644 index 0000000..5acb1c6 --- /dev/null +++ b/lua/oil/mutator/progress.lua @@ -0,0 +1,77 @@ +local columns = require("oil.columns") +local loading = require("oil.loading") +local util = require("oil.util") +local Progress = {} + +local FPS = 20 + +function Progress.new() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].bufhidden = "wipe" + return setmetatable({ + lines = { "", "", "" }, + bufnr = bufnr, + }, { + __index = Progress, + }) +end + +function Progress:show() + if self.winid and vim.api.nvim_win_is_valid(self.winid) then + return + end + local loading_iter = loading.get_bar_iter() + self.timer = vim.loop.new_timer() + self.timer:start( + 0, + math.floor(1000 / FPS), + vim.schedule_wrap(function() + self.lines[2] = loading_iter() + self:_render() + end) + ) + local width = 120 + local height = 10 + self.winid = vim.api.nvim_open_win(self.bufnr, true, { + relative = "editor", + width = width, + height = height, + row = math.floor((vim.o.lines - vim.o.cmdheight - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + style = "minimal", + border = "rounded", + }) +end + +function Progress:_render() + util.render_centered_text(self.bufnr, self.lines) +end + +---@param action oil.Action +---@param idx integer +---@param total integer +function Progress:set_action(action, idx, total) + local adapter = util.get_adapter_for_action(action) + local change_line + if action.type == "change" then + change_line = columns.render_change_action(adapter, action) + else + change_line = adapter.render_action(action) + end + self.lines[1] = change_line + self.lines[3] = string.format("[%d/%d]", idx, total) + self:_render() +end + +function Progress:close() + if self.timer then + self.timer:close() + self.timer = nil + end + if self.winid then + vim.api.nvim_win_close(self.winid, true) + self.winid = nil + end +end + +return Progress diff --git a/lua/oil/mutator/trie.lua b/lua/oil/mutator/trie.lua new file mode 100644 index 0000000..cb10bbd --- /dev/null +++ b/lua/oil/mutator/trie.lua @@ -0,0 +1,153 @@ +local util = require("oil.util") +local Trie = {} + +Trie.new = function() + return setmetatable({ + root = { values = {}, children = {} }, + }, { + __index = Trie, + }) +end + +---@param url string +---@return string[] +function Trie:_url_to_path_pieces(url) + local scheme, path = util.parse_url(url) + local pieces = vim.split(path, "/") + table.insert(pieces, 1, scheme) + return pieces +end + +---@param url string +---@param value any +function Trie:insert_action(url, value) + local pieces = self:_url_to_path_pieces(url) + self:insert(pieces, value) +end + +---@param url string +---@param value any +function Trie:remove_action(url, value) + local pieces = self:_url_to_path_pieces(url) + self:remove(pieces, value) +end + +---@param path_pieces string[] +---@param value any +function Trie:insert(path_pieces, value) + local current = self.root + for _, piece in ipairs(path_pieces) do + local next_container = current.children[piece] + if not next_container then + next_container = { values = {}, children = {} } + current.children[piece] = next_container + end + current = next_container + end + table.insert(current.values, value) +end + +---@param path_pieces string[] +---@param value any +function Trie:remove(path_pieces, value) + local current = self.root + for _, piece in ipairs(path_pieces) do + local next_container = current.children[piece] + if not next_container then + next_container = { values = {}, children = {} } + current.children[piece] = next_container + end + current = next_container + end + for i, v in ipairs(current.values) do + if v == value then + table.remove(current.values, i) + -- if vim.tbl_isempty(current.values) and vim.tbl_isempty(current.children) then + -- TODO remove container from trie + -- end + return + end + end + error("Value not present in trie: " .. vim.inspect(value)) +end + +---Add the first action that affects a parent path of the url +---@param url string +---@param ret oil.InternalEntry[] +function Trie:accum_first_parents_of(url, ret) + local pieces = self:_url_to_path_pieces(url) + local containers = { self.root } + for _, piece in ipairs(pieces) do + local next_container = containers[#containers].children[piece] + table.insert(containers, next_container) + end + table.remove(containers) + while not vim.tbl_isempty(containers) do + local container = containers[#containers] + if not vim.tbl_isempty(container.values) then + vim.list_extend(ret, container.values) + break + end + table.remove(containers) + end +end + +---Do a depth-first-search and add all children matching the filter +function Trie:_dfs(container, ret, filter) + if filter then + for _, action in ipairs(container.values) do + if filter(action) then + table.insert(ret, action) + end + end + else + vim.list_extend(ret, container.values) + end + for _, child in ipairs(container.children) do + self:_dfs(child, ret) + end +end + +---Add all actions affecting children of the url +---@param url string +---@param ret oil.InternalEntry[] +---@param filter nil|fun(entry: oil.InternalEntry): boolean +function Trie:accum_children_of(url, ret, filter) + local pieces = self:_url_to_path_pieces(url) + local current = self.root + for _, piece in ipairs(pieces) do + current = current.children[piece] + if not current then + return + end + end + if current then + for _, child in pairs(current.children) do + self:_dfs(child, ret, filter) + end + end +end + +---Add all actions at a specific path +---@param url string +---@param ret oil.InternalEntry[] +---@param filter nil|fun(entry: oil.InternalEntry): boolean +function Trie:accum_actions_at(url, ret, filter) + local pieces = self:_url_to_path_pieces(url) + local current = self.root + for _, piece in ipairs(pieces) do + current = current.children[piece] + if not current then + return + end + end + if current then + for _, action in ipairs(current.values) do + if not filter or filter(action) then + table.insert(ret, action) + end + end + end +end + +return Trie diff --git a/lua/oil/pathutil.lua b/lua/oil/pathutil.lua new file mode 100644 index 0000000..1c8877c --- /dev/null +++ b/lua/oil/pathutil.lua @@ -0,0 +1,37 @@ +local fs = require("oil.fs") +local M = {} + +---@param path string +---@return string +M.parent = function(path) + -- Do I love this hack? No I do not. + -- Does it work? Yes. Mostly. For now. + if fs.is_windows then + if path:match("^/%a+/?$") then + return path + end + end + if path == "/" then + return "/" + elseif path == "" then + return "" + elseif vim.endswith(path, "/") then + return path:match("^(.*/)[^/]*/$") or "" + else + return path:match("^(.*/)[^/]*$") or "" + end +end + +---@param path string +---@return nil|string +M.basename = function(path) + if path == "/" or path == "" then + return + elseif vim.endswith(path, "/") then + return path:match("^.*/([^/]*)/$") + else + return path:match("^.*/([^/]*)$") + end +end + +return M diff --git a/lua/oil/repl_layout.lua b/lua/oil/repl_layout.lua new file mode 100644 index 0000000..0224ab1 --- /dev/null +++ b/lua/oil/repl_layout.lua @@ -0,0 +1,140 @@ +local util = require("oil.util") +local ReplLayout = {} + +---@param opts table +--- min_height integer +--- min_width integer +--- lines string[] +--- on_submit fun(text: string): boolean +--- on_cancel nil|fun() +ReplLayout.new = function(opts) + opts = vim.tbl_deep_extend("keep", opts or {}, { + min_height = 10, + min_width = 120, + }) + vim.validate({ + lines = { opts.lines, "t" }, + min_height = { opts.min_height, "n" }, + min_width = { opts.min_width, "n" }, + on_submit = { opts.on_submit, "f" }, + on_cancel = { opts.on_cancel, "f", true }, + }) + local total_height = util.get_editor_height() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].bufhidden = "wipe" + local width = math.min(opts.min_width, vim.o.columns - 2) + local height = math.min(opts.min_height, total_height - 3) + local row = math.floor((util.get_editor_height() - height) / 2) + local col = math.floor((vim.o.columns - width) / 2) + local view_winid = vim.api.nvim_open_win(bufnr, false, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + focusable = false, + }) + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, opts.lines) + vim.bo[bufnr].modified = false + vim.bo[bufnr].modifiable = false + vim.api.nvim_win_set_cursor(view_winid, { #opts.lines, 0 }) + + local input_bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[input_bufnr].bufhidden = "wipe" + local input_winid = vim.api.nvim_open_win(input_bufnr, true, { + relative = "editor", + width = width, + height = 1, + row = row + height + 2, + col = col, + style = "minimal", + border = "rounded", + }) + + vim.api.nvim_create_autocmd("WinClosed", { + desc = "Close oil repl window when text input closes", + pattern = tostring(input_winid), + callback = function() + if view_winid and vim.api.nvim_win_is_valid(view_winid) then + vim.api.nvim_win_close(view_winid, true) + end + end, + once = true, + nested = true, + }) + + local self = setmetatable({ + input_bufnr = input_bufnr, + view_bufnr = bufnr, + input_winid = input_winid, + view_winid = view_winid, + _cancel = nil, + _submit = nil, + }, { + __index = ReplLayout, + }) + self._cancel = function() + self:close() + if opts.on_cancel then + opts.on_cancel() + end + end + self._submit = function() + local line = vim.trim(vim.api.nvim_buf_get_lines(input_bufnr, 0, 1, true)[1]) + if line == "" then + return + end + if not opts.on_submit(line) then + vim.api.nvim_buf_set_lines(input_bufnr, 0, -1, true, {}) + vim.bo[input_bufnr].modified = false + end + end + local cancel = function() + self._cancel() + end + vim.api.nvim_create_autocmd("BufLeave", { + callback = cancel, + once = true, + nested = true, + buffer = input_bufnr, + }) + vim.api.nvim_create_autocmd("WinLeave", { + callback = cancel, + once = true, + nested = true, + }) + vim.keymap.set("n", "", cancel, { buffer = input_bufnr }) + vim.keymap.set({ "n", "i" }, "", cancel, { buffer = input_bufnr }) + vim.keymap.set({ "n", "i" }, "", function() + self._submit() + end, { buffer = input_bufnr }) + vim.cmd.startinsert() + return self +end + +function ReplLayout:append_view_lines(lines) + local bufnr = self.view_bufnr + local num_lines = vim.api.nvim_buf_line_count(bufnr) + local last_line = vim.api.nvim_buf_get_lines(bufnr, num_lines - 1, num_lines, true)[1] + lines[1] = last_line .. lines[1] + for i, v in ipairs(lines) do + lines[i] = v:gsub("\r$", "") + end + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, num_lines - 1, -1, true, lines) + vim.bo[bufnr].modifiable = false + vim.bo[bufnr].modified = false + vim.api.nvim_win_set_cursor(self.view_winid, { num_lines + #lines - 1, 0 }) +end + +function ReplLayout:close() + self._submit = function() end + self._cancel = function() end + vim.cmd.stopinsert() + vim.api.nvim_win_close(self.input_winid, true) +end + +return ReplLayout diff --git a/lua/oil/shell.lua b/lua/oil/shell.lua new file mode 100644 index 0000000..63cb823 --- /dev/null +++ b/lua/oil/shell.lua @@ -0,0 +1,40 @@ +local M = {} + +M.run = function(cmd, callback) + local stdout + local stderr = {} + local jid = vim.fn.jobstart(cmd, { + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(j, output) + stdout = output + end, + on_stderr = function(j, output) + stderr = output + end, + on_exit = vim.schedule_wrap(function(j, code) + if code == 0 then + callback(nil, stdout) + else + local err = table.concat(stderr, "\n") + if err == "" then + err = "Unknown error" + end + callback(err) + end + end), + }) + local exe + if type(cmd) == "string" then + exe = vim.split(cmd, "%s+")[1] + else + exe = cmd[1] + end + if jid == 0 then + callback(string.format("Passed invalid arguments to '%s'", exe)) + elseif jid == -1 then + callback(string.format("'%s' is not executable", exe)) + end +end + +return M diff --git a/lua/oil/util.lua b/lua/oil/util.lua new file mode 100644 index 0000000..bcdc989 --- /dev/null +++ b/lua/oil/util.lua @@ -0,0 +1,500 @@ +local config = require("oil.config") +local M = {} + +---@param url string +---@return nil|string +---@return nil|string +M.parse_url = function(url) + return url:match("^(.*://)(.*)$") +end + +---@param bufnr integer +---@return nil|oil.Adapter +M.get_adapter = function(bufnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local adapter = config.get_adapter_by_scheme(bufname) + if not adapter then + vim.notify_once( + string.format("[oil] could not find adapter for buffer '%s://'", bufname), + vim.log.levels.ERROR + ) + end + return adapter +end + +---@param text string +---@param length nil|integer +---@return string +M.rpad = function(text, length) + if not length then + return text + end + local textlen = vim.api.nvim_strwidth(text) + local delta = length - textlen + if delta > 0 then + return text .. string.rep(" ", delta) + else + return text + end +end + +---@param text string +---@param length nil|integer +---@return string +M.lpad = function(text, length) + if not length then + return text + end + local textlen = vim.api.nvim_strwidth(text) + local delta = length - textlen + if delta > 0 then + return string.rep(" ", delta) .. text + else + return text + end +end + +---@generic T : any +---@param tbl T[] +---@param start_idx? number +---@param end_idx? number +---@return T[] +M.tbl_slice = function(tbl, start_idx, end_idx) + local ret = {} + if not start_idx then + start_idx = 1 + end + if not end_idx then + end_idx = #tbl + end + for i = start_idx, end_idx do + table.insert(ret, tbl[i]) + end + return ret +end + +---@param entry oil.InternalEntry +---@return oil.Entry +M.export_entry = function(entry) + local FIELD = require("oil.constants").FIELD + return { + name = entry[FIELD.name], + type = entry[FIELD.type], + id = entry[FIELD.id], + meta = entry[FIELD.meta], + } +end + +---@param src_bufnr integer|string Buffer number or name +---@param dest_buf_name string +M.rename_buffer = function(src_bufnr, dest_buf_name) + if type(src_bufnr) == "string" then + src_bufnr = vim.fn.bufadd(src_bufnr) + if not vim.api.nvim_buf_is_loaded(src_bufnr) then + vim.api.nvim_buf_delete(src_bufnr, {}) + return + end + end + + local bufname = vim.api.nvim_buf_get_name(src_bufnr) + local scheme = M.parse_url(bufname) + -- If this buffer has a scheme (is not literally a file on disk), then we can use the simple + -- rename logic. The only reason we can't use nvim_buf_set_name on files is because vim will + -- think that the new buffer conflicts with the file next time it tries to save. + if scheme or vim.fn.isdirectory(bufname) == 1 then + -- This will fail if the dest buf name already exists + local ok = pcall(vim.api.nvim_buf_set_name, src_bufnr, dest_buf_name) + if ok then + -- Renaming the buffer creates a new buffer with the old name. Find it and delete it. + vim.api.nvim_buf_delete(vim.fn.bufadd(bufname), {}) + return + end + end + + local dest_bufnr = vim.fn.bufadd(dest_buf_name) + vim.fn.bufload(dest_bufnr) + if vim.bo[src_bufnr].buflisted then + vim.bo[dest_bufnr].buflisted = true + end + -- Find any windows with the old buffer and replace them + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) then + if vim.api.nvim_win_get_buf(winid) == src_bufnr then + vim.api.nvim_win_set_buf(winid, dest_bufnr) + end + end + end + if vim.bo[src_bufnr].modified then + local src_lines = vim.api.nvim_buf_get_lines(src_bufnr, 0, -1, true) + vim.api.nvim_buf_set_lines(dest_bufnr, 0, -1, true, src_lines) + end + -- Try to delete, but don't if the buffer has changes + pcall(vim.api.nvim_buf_delete, src_bufnr, {}) +end + +---@param count integer +---@param cb fun(err: nil|string) +M.cb_collect = function(count, cb) + return function(err) + if err then + cb(err) + cb = function() end + else + count = count - 1 + if count == 0 then + cb() + end + end + end +end + +---@param url string +---@return string[] +local function get_possible_buffer_names_from_url(url) + local fs = require("oil.fs") + local scheme, path = M.parse_url(url) + local ret = {} + for k, v in pairs(config.remap_schemes) do + if v == scheme then + if k ~= "default" then + table.insert(ret, k .. path) + end + end + end + if vim.tbl_isempty(ret) then + return { fs.posix_to_os_path(path) } + else + return ret + end +end + +---@param entry_type oil.EntryType +---@param src_url string +---@param dest_url string +M.update_moved_buffers = function(entry_type, src_url, dest_url) + local src_buf_names = get_possible_buffer_names_from_url(src_url) + local dest_buf_name = get_possible_buffer_names_from_url(dest_url)[1] + if entry_type ~= "directory" then + for _, src_buf_name in ipairs(src_buf_names) do + M.rename_buffer(src_buf_name, dest_buf_name) + end + else + M.rename_buffer(M.addslash(src_url), M.addslash(dest_url)) + -- If entry type is directory, we need to rename this buffer, and then update buffers that are + -- inside of this directory + + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + local bufname = vim.api.nvim_buf_get_name(bufnr) + if vim.startswith(bufname, src_url) then + -- Handle oil directory buffers + vim.api.nvim_buf_set_name(bufnr, dest_url .. bufname:sub(src_url:len() + 1)) + elseif bufname ~= "" and vim.bo[bufnr].buftype == "" then + -- Handle regular buffers + local scheme = M.parse_url(bufname) + + -- If the buffer is a local file, make sure we're using the absolute path + if not scheme then + bufname = vim.fn.fnamemodify(bufname, ":p") + end + + for _, src_buf_name in ipairs(src_buf_names) do + if vim.startswith(bufname, src_buf_name) then + M.rename_buffer(bufnr, dest_buf_name .. bufname:sub(src_buf_name:len() + 1)) + break + end + end + end + end + end +end + +---@param name_or_config string|table +---@return string +---@return table|nil +M.split_config = function(name_or_config) + if type(name_or_config) == "string" then + return name_or_config, nil + else + if not name_or_config[1] and name_or_config["1"] then + -- This was likely loaded from json, so the first element got coerced to a string key + name_or_config[1] = name_or_config["1"] + name_or_config["1"] = nil + end + return name_or_config[1], name_or_config + end +end + +---@param lines oil.TextChunk[][] +---@param col_width integer[] +---@return string[] +---@return any[][] List of highlights {group, lnum, col_start, col_end} +M.render_table = function(lines, col_width) + local str_lines = {} + local highlights = {} + for _, cols in ipairs(lines) do + local col = 0 + local pieces = {} + for i, chunk in ipairs(cols) do + local text, hl + if type(chunk) == "table" then + text, hl = unpack(chunk) + else + text = chunk + end + text = M.rpad(text, col_width[i]) + table.insert(pieces, text) + local col_end = col + text:len() + 1 + if hl then + table.insert(highlights, { hl, #str_lines, col, col_end }) + end + col = col_end + end + table.insert(str_lines, table.concat(pieces, " ")) + end + return str_lines, highlights +end + +---@param bufnr integer +---@param highlights any[][] List of highlights {group, lnum, col_start, col_end} +M.set_highlights = function(bufnr, highlights) + local ns = vim.api.nvim_create_namespace("Oil") + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + for _, hl in ipairs(highlights) do + vim.api.nvim_buf_add_highlight(bufnr, ns, unpack(hl)) + end +end + +---@param path string +---@return string +M.addslash = function(path) + if not vim.endswith(path, "/") then + return path .. "/" + else + return path + end +end + +---@param winid nil|integer +---@return boolean +M.is_floating_win = function(winid) + return vim.api.nvim_win_get_config(winid or 0).relative ~= "" +end + +---@return integer +M.get_editor_height = function() + local total_height = vim.o.lines - vim.o.cmdheight + if vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1) then + total_height = total_height - 1 + end + if + vim.o.laststatus >= 2 or (vim.o.laststatus == 1 and #vim.api.nvim_tabpage_list_wins(0) > 1) + then + total_height = total_height - 1 + end + return total_height +end + +local winid_map = {} +M.add_title_to_win = function(winid, title, opts) + opts = opts or {} + opts.align = opts.align or "left" + if not vim.api.nvim_win_is_valid(winid) then + return + end + -- HACK to force the parent window to position itself + -- See https://github.com/neovim/neovim/issues/13403 + vim.cmd.redraw() + local width = math.min(vim.api.nvim_win_get_width(winid) - 4, 2 + vim.api.nvim_strwidth(title)) + local title_winid = winid_map[winid] + local bufnr + if title_winid and vim.api.nvim_win_is_valid(title_winid) then + vim.api.nvim_win_set_width(title_winid, width) + bufnr = vim.api.nvim_win_get_buf(title_winid) + else + bufnr = vim.api.nvim_create_buf(false, true) + local col = 1 + if opts.align == "center" then + col = math.floor((vim.api.nvim_win_get_width(winid) - width) / 2) + elseif opts.align == "right" then + col = vim.api.nvim_win_get_width(winid) - 1 - width + elseif opts.align ~= "left" then + vim.notify( + string.format("Unknown oil window title alignment: '%s'", opts.align), + vim.log.levels.ERROR + ) + end + title_winid = vim.api.nvim_open_win(bufnr, false, { + relative = "win", + win = winid, + width = width, + height = 1, + row = -1, + col = col, + focusable = false, + zindex = 151, + style = "minimal", + noautocmd = true, + }) + winid_map[winid] = title_winid + vim.api.nvim_win_set_option( + title_winid, + "winblend", + vim.api.nvim_win_get_option(winid, "winblend") + ) + vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") + + local update_autocmd = vim.api.nvim_create_autocmd("BufWinEnter", { + desc = "Update oil floating window title when buffer changes", + pattern = "*", + callback = function(params) + local winbuf = params.buf + if vim.api.nvim_win_get_buf(winid) ~= winbuf then + return + end + local bufname = vim.api.nvim_buf_get_name(winbuf) + local new_width = + math.min(vim.api.nvim_win_get_width(winid) - 4, 2 + vim.api.nvim_strwidth(bufname)) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, { " " .. bufname .. " " }) + vim.bo[bufnr].modified = false + vim.api.nvim_win_set_width(title_winid, new_width) + local new_col = 1 + if opts.align == "center" then + new_col = math.floor((vim.api.nvim_win_get_width(winid) - new_width) / 2) + elseif opts.align == "right" then + new_col = vim.api.nvim_win_get_width(winid) - 1 - new_width + end + vim.api.nvim_win_set_config(title_winid, { + relative = "win", + win = winid, + row = -1, + col = new_col, + width = new_width, + height = 1, + }) + end, + }) + vim.api.nvim_create_autocmd("WinClosed", { + desc = "Close oil floating window title when floating window closes", + pattern = tostring(winid), + callback = function() + if title_winid and vim.api.nvim_win_is_valid(title_winid) then + vim.api.nvim_win_close(title_winid, true) + end + winid_map[winid] = nil + vim.api.nvim_del_autocmd(update_autocmd) + end, + once = true, + nested = true, + }) + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, { " " .. title .. " " }) + vim.bo[bufnr].modified = false + vim.api.nvim_win_set_option( + title_winid, + "winhighlight", + "Normal:FloatTitle,NormalFloat:FloatTitle" + ) +end + +---@param action oil.Action +---@return oil.Adapter +M.get_adapter_for_action = function(action) + local adapter = config.get_adapter_by_scheme(action.url or action.src_url) + if not adapter then + error("no adapter found") + end + if action.dest_url then + local dest_adapter = config.get_adapter_by_scheme(action.dest_url) + if adapter ~= dest_adapter then + if adapter.supports_xfer and adapter.supports_xfer[dest_adapter.name] then + return adapter + elseif dest_adapter.supports_xfer and dest_adapter.supports_xfer[adapter.name] then + return dest_adapter + else + error( + string.format( + "Cannot copy files from %s -> %s; no cross-adapter transfer method found", + action.src_url, + action.dest_url + ) + ) + end + end + end + return adapter +end + +M.render_centered_text = function(bufnr, text) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + if type(text) == "string" then + text = { text } + end + local winid + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == bufnr then + winid = win + break + end + end + local height = 40 + local width = 30 + if winid then + height = vim.api.nvim_win_get_height(winid) + width = vim.api.nvim_win_get_width(winid) + end + local lines = {} + for _ = 1, (height / 2) - (#text / 2) do + table.insert(lines, "") + end + for _, line in ipairs(text) do + line = string.rep(" ", (width - vim.api.nvim_strwidth(line)) / 2) .. line + table.insert(lines, line) + end + vim.api.nvim_buf_set_option(bufnr, "modifiable", true) + pcall(vim.api.nvim_buf_set_lines, bufnr, 0, -1, false, lines) + vim.api.nvim_buf_set_option(bufnr, "modifiable", false) + vim.bo[bufnr].modified = false +end + +---Run a function in the context of a full-editor window +---@param bufnr nil|integer +---@param callback fun() +M.run_in_fullscreen_win = function(bufnr, callback) + if not bufnr then + bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") + end + local winid = vim.api.nvim_open_win(bufnr, false, { + relative = "editor", + width = vim.o.columns, + height = vim.o.lines, + row = 0, + col = 0, + noautocmd = true, + }) + local winnr = vim.api.nvim_win_get_number(winid) + vim.cmd.wincmd({ count = winnr, args = { "w" }, mods = { noautocmd = true } }) + callback() + vim.cmd.close({ count = winnr, mods = { noautocmd = true, emsg_silent = true } }) +end + +---This is a hack so we don't end up in insert mode after starting a task +---@param prev_mode string The vim mode we were in before opening a terminal +M.hack_around_termopen_autocmd = function(prev_mode) + -- It's common to have autocmds that enter insert mode when opening a terminal + vim.defer_fn(function() + local new_mode = vim.api.nvim_get_mode().mode + if new_mode ~= prev_mode then + if string.find(new_mode, "i") == 1 then + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, true, true), "n", false) + if string.find(prev_mode, "v") == 1 or string.find(prev_mode, "V") == 1 then + vim.cmd.normal({ bang = true, args = { "gv" } }) + end + end + end + end, 10) +end + +return M diff --git a/lua/oil/view.lua b/lua/oil/view.lua new file mode 100644 index 0000000..d7cc062 --- /dev/null +++ b/lua/oil/view.lua @@ -0,0 +1,430 @@ +local cache = require("oil.cache") +local columns = require("oil.columns") +local config = require("oil.config") +local keymap_util = require("oil.keymap_util") +local loading = require("oil.loading") +local util = require("oil.util") +local FIELD = require("oil.constants").FIELD +local M = {} + +-- map of path->last entry under cursor +local last_cursor_entry = {} + +---@param entry oil.InternalEntry +---@return boolean +M.should_display = function(entry) + local name = entry[FIELD.name] + if not config.view_options.show_hidden and vim.startswith(name, ".") then + return false + end + return true +end + +---@param bufname string +---@param name string +M.set_last_cursor = function(bufname, name) + last_cursor_entry[bufname] = name +end + +---@param bufname string +---@return nil|string +M.get_last_cursor = function(bufname) + return last_cursor_entry[bufname] +end + +local function are_any_modified() + local view = require("oil.view") + local buffers = view.get_all_buffers() + for _, bufnr in ipairs(buffers) do + if vim.bo[bufnr].modified then + return true + end + end + return false +end + +M.toggle_hidden = function() + local view = require("oil.view") + local any_modified = are_any_modified() + if any_modified then + vim.notify("Cannot toggle hidden files when you have unsaved changes", vim.log.levels.WARN) + else + config.view_options.show_hidden = not config.view_options.show_hidden + view.rerender_visible_and_cleanup({ refetch = false }) + end +end + +M.set_columns = function(cols) + local view = require("oil.view") + local any_modified = are_any_modified() + if any_modified then + vim.notify("Cannot change columns when you have unsaved changes", vim.log.levels.WARN) + else + config.columns = cols + -- TODO only refetch if we don't have all the necessary data for the columns + view.rerender_visible_and_cleanup({ refetch = true }) + end +end + +-- List of bufnrs +local session = {} + +---@return integer[] +M.get_all_buffers = function() + return vim.tbl_filter(vim.api.nvim_buf_is_loaded, vim.tbl_keys(session)) +end + +---@param opts table +---@note +--- This DISCARDS ALL MODIFICATIONS a user has made to oil buffers +M.rerender_visible_and_cleanup = function(opts) + local buffers = M.get_all_buffers() + local hidden_buffers = {} + for _, bufnr in ipairs(buffers) do + hidden_buffers[bufnr] = true + end + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) then + hidden_buffers[vim.api.nvim_win_get_buf(winid)] = nil + end + end + for _, bufnr in ipairs(buffers) do + if hidden_buffers[bufnr] then + vim.api.nvim_buf_delete(bufnr, { force = true }) + else + M.render_buffer_async(bufnr, opts) + end + end +end + +M.set_win_options = function() + local winid = vim.api.nvim_get_current_win() + for k, v in pairs(config.win_options) do + if config.restore_win_options then + local varname = "_oil_" .. k + if not pcall(vim.api.nvim_win_get_var, winid, varname) then + local prev_value = vim.wo[k] + vim.api.nvim_win_set_var(winid, varname, prev_value) + end + end + vim.api.nvim_win_set_option(winid, k, v) + end +end + +M.restore_win_options = function() + local winid = vim.api.nvim_get_current_win() + for k in pairs(config.win_options) do + local varname = "_oil_" .. k + local has_opt, opt = pcall(vim.api.nvim_win_get_var, winid, varname) + if has_opt then + vim.api.nvim_win_set_option(winid, k, opt) + end + end +end + +---Delete hidden oil buffers and if none remain, clear the cache +M.cleanup = function() + local buffers = M.get_all_buffers() + local hidden_buffers = {} + for _, bufnr in ipairs(buffers) do + if vim.bo[bufnr].modified then + return + end + hidden_buffers[bufnr] = true + end + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) then + hidden_buffers[vim.api.nvim_win_get_buf(winid)] = nil + end + end + + local any_remaining = false + for _, bufnr in ipairs(buffers) do + if hidden_buffers[bufnr] then + vim.api.nvim_buf_delete(bufnr, { force = true }) + else + any_remaining = true + end + end + if not any_remaining then + cache.clear_everything() + end +end + +---@param bufnr integer +M.initialize = function(bufnr) + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + session[bufnr] = true + vim.bo[bufnr].buftype = "acwrite" + vim.bo[bufnr].filetype = "oil" + vim.bo[bufnr].bufhidden = "hide" + vim.bo[bufnr].syntax = "oil" + M.set_win_options() + vim.api.nvim_create_autocmd("BufHidden", { + callback = function() + vim.defer_fn(M.cleanup, 2000) + end, + nested = true, + buffer = bufnr, + }) + vim.api.nvim_create_autocmd("BufDelete", { + callback = function() + session[bufnr] = nil + end, + nested = true, + once = true, + buffer = bufnr, + }) + M.render_buffer_async(bufnr, {}, function(err) + if err then + vim.notify( + string.format("Error rendering oil buffer %s: %s", vim.api.nvim_buf_get_name(bufnr), err), + vim.log.levels.ERROR + ) + end + end) + keymap_util.set_keymaps("", config.keymaps, bufnr) +end + +---@param bufnr integer +---@param opts nil|table +--- jump boolean +--- jump_first boolean +---@return boolean +local function render_buffer(bufnr, opts) + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + if not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + local bufname = vim.api.nvim_buf_get_name(bufnr) + opts = vim.tbl_extend("keep", opts or {}, { + jump = false, + jump_first = false, + }) + local scheme = util.parse_url(bufname) + local adapter = util.get_adapter(bufnr) + if not adapter then + return false + end + local entries = cache.list_url(bufname) + local entry_list = vim.tbl_values(entries) + + table.sort(entry_list, function(a, b) + local a_isdir = a[FIELD.type] == "directory" + local b_isdir = b[FIELD.type] == "directory" + if a_isdir ~= b_isdir then + return a_isdir + end + return a[FIELD.name] < b[FIELD.name] + end) + + local jump_idx + if opts.jump_first then + jump_idx = 1 + end + local seek_after_render_found = false + local seek_after_render = M.get_last_cursor(bufname) + local column_defs = columns.get_supported_columns(scheme) + local line_table = {} + local col_width = {} + for i in ipairs(column_defs) do + col_width[i + 1] = 1 + end + local virt_text = {} + for _, entry in ipairs(entry_list) do + if not M.should_display(entry) then + goto continue + end + local cols = M.format_entry_cols(entry, column_defs, col_width, adapter) + table.insert(line_table, cols) + + local name = entry[FIELD.name] + if seek_after_render == name then + seek_after_render_found = true + jump_idx = #line_table + end + ::continue:: + end + + local lines, highlights = util.render_table(line_table, col_width) + + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) + vim.bo[bufnr].modifiable = false + vim.bo[bufnr].modified = false + util.set_highlights(bufnr, highlights) + local ns = vim.api.nvim_create_namespace("Oil") + for _, v in ipairs(virt_text) do + local lnum, col, ext_opts = unpack(v) + vim.api.nvim_buf_set_extmark(bufnr, ns, lnum, col, ext_opts) + end + if opts.jump then + -- TODO why is the schedule necessary? + vim.schedule(function() + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == bufnr then + -- If we're not jumping to a specific lnum, use the current lnum so we can adjust the col + local lnum = jump_idx or vim.api.nvim_win_get_cursor(winid)[1] + local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1] + local id_str = line:match("^/(%d+)") + local id = tonumber(id_str) + if id then + local entry = cache.get_entry_by_id(id) + if entry then + local name = entry[FIELD.name] + local col = line:find(name, 1, true) or (id_str:len() + 1) + vim.api.nvim_win_set_cursor(winid, { lnum, col - 1 }) + end + end + end + end + end) + end + return seek_after_render_found +end + +---@private +---@param adapter oil.Adapter +---@param entry oil.InternalEntry +---@param column_defs table[] +---@param col_width integer[] +---@param adapter oil.Adapter +---@return oil.TextChunk[] +M.format_entry_cols = function(entry, column_defs, col_width, adapter) + local name = entry[FIELD.name] + -- First put the unique ID + local cols = {} + local id_key = cache.format_id(entry[FIELD.id]) + col_width[1] = id_key:len() + table.insert(cols, id_key) + -- Then add all the configured columns + for i, column in ipairs(column_defs) do + local chunk = columns.render_col(adapter, column, entry) + local text = type(chunk) == "table" and chunk[1] or chunk + col_width[i + 1] = math.max(col_width[i + 1], vim.api.nvim_strwidth(text)) + table.insert(cols, chunk) + end + -- Always add the entry name at the end + local entry_type = entry[FIELD.type] + if entry_type == "directory" then + table.insert(cols, { name .. "/", "OilDir" }) + elseif entry_type == "socket" then + table.insert(cols, { name, "OilSocket" }) + elseif entry_type == "link" then + local meta = entry[FIELD.meta] + local link_text + if meta then + if meta.link_stat and meta.link_stat.type == "directory" then + name = name .. "/" + end + + if meta.link then + link_text = "->" .. " " .. meta.link + if meta.link_stat and meta.link_stat.type == "directory" then + link_text = util.addslash(link_text) + end + end + end + + table.insert(cols, { name, "OilLink" }) + if link_text then + table.insert(cols, { link_text, "Comment" }) + end + else + table.insert(cols, { name, "OilFile" }) + end + return cols +end + +---@param bufnr integer +---@param opts nil|table +--- preserve_undo nil|boolean +--- refetch nil|boolean Defaults to true +---@param callback nil|fun(err: nil|string) +M.render_buffer_async = function(bufnr, opts, callback) + opts = vim.tbl_deep_extend("keep", opts or {}, { + preserve_undo = false, + refetch = true, + }) + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + local bufname = vim.api.nvim_buf_get_name(bufnr) + local scheme, dir = util.parse_url(bufname) + local preserve_undo = opts.preserve_undo and config.adapters[scheme] == "files" + if not preserve_undo then + -- Undo should not return to a blank buffer + -- Method taken from :h clear-undo + vim.bo[bufnr].undolevels = -1 + end + local handle_error = vim.schedule_wrap(function(message) + if not preserve_undo then + vim.bo[bufnr].undolevels = vim.api.nvim_get_option("undolevels") + end + util.render_centered_text(bufnr, { "Error: " .. message }) + if callback then + callback(message) + else + error(message) + end + end) + if not dir then + handle_error(string.format("Could not parse oil url '%s'", bufname)) + return + end + local adapter = util.get_adapter(bufnr) + if not adapter then + handle_error(string.format("[oil] no adapter for buffer '%s'", bufname)) + return + end + local start_ms = vim.loop.hrtime() / 1e6 + local seek_after_render_found = false + local first = true + vim.bo[bufnr].modifiable = false + loading.set_loading(bufnr, true) + + local finish = vim.schedule_wrap(function() + loading.set_loading(bufnr, false) + render_buffer(bufnr, { jump = true }) + if not preserve_undo then + vim.bo[bufnr].undolevels = vim.api.nvim_get_option("undolevels") + end + vim.bo[bufnr].modifiable = adapter.is_modifiable(bufnr) + if callback then + callback() + end + end) + if not opts.refetch then + finish() + return + end + + adapter.list(bufname, config.columns, function(err, has_more) + loading.set_loading(bufnr, false) + if err then + handle_error(err) + return + elseif has_more then + local now = vim.loop.hrtime() / 1e6 + local delta = now - start_ms + -- If we've been chugging for more than 40ms, go ahead and render what we have + if delta > 40 then + start_ms = now + vim.schedule(function() + seek_after_render_found = + render_buffer(bufnr, { jump = not seek_after_render_found, jump_first = first }) + end) + end + first = false + else + -- done iterating + finish() + end + end) +end + +return M diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..98b4fa7 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +mkdir -p ".testenv/config/nvim" +mkdir -p ".testenv/data/nvim" +mkdir -p ".testenv/state/nvim" +mkdir -p ".testenv/run/nvim" +mkdir -p ".testenv/cache/nvim" +PLUGINS=".testenv/data/nvim/site/pack/plugins/start" + +if [ ! -e "$PLUGINS/plenary.nvim" ]; then + git clone --depth=1 https://github.com/nvim-lua/plenary.nvim.git "$PLUGINS/plenary.nvim" +else + (cd "$PLUGINS/plenary.nvim" && git pull) +fi + +XDG_CONFIG_HOME=".testenv/config" \ + XDG_DATA_HOME=".testenv/data" \ + XDG_STATE_HOME=".testenv/state" \ + XDG_RUNTIME_DIR=".testenv/run" \ + XDG_CACHE_HOME=".testenv/cache" \ + nvim --headless -u tests/minimal_init.lua \ + -c "PlenaryBustedDirectory ${1-tests} { minimal_init = './tests/minimal_init.lua' }" +echo "Success" diff --git a/syntax/oil.vim b/syntax/oil.vim new file mode 100644 index 0000000..0f2a4a3 --- /dev/null +++ b/syntax/oil.vim @@ -0,0 +1,7 @@ +if exists("b:current_syntax") + finish +endif + +syn match oilId /^\/\d* / conceal + +let b:current_syntax = "oil" diff --git a/syntax/oil_preview.vim b/syntax/oil_preview.vim new file mode 100644 index 0000000..41656ee --- /dev/null +++ b/syntax/oil_preview.vim @@ -0,0 +1,11 @@ +if exists("b:current_syntax") + finish +endif + +syn match oilCreate /^CREATE / +syn match oilMove /^ MOVE / +syn match oilDelete /^DELETE / +syn match oilCopy /^ COPY / +syn match oilChange /^CHANGE / + +let b:current_syntax = "oil_preview" diff --git a/tests/files_spec.lua b/tests/files_spec.lua new file mode 100644 index 0000000..e254608 --- /dev/null +++ b/tests/files_spec.lua @@ -0,0 +1,311 @@ +require("plenary.async").tests.add_to_env() +local fs = require("oil.fs") +local files = require("oil.adapters.files") +local cache = require("oil.cache") + +local function throwiferr(err, ...) + if err then + error(err) + else + return ... + end +end + +local function await(fn, nargs, ...) + return throwiferr(a.wrap(fn, nargs)(...)) +end + +---@param path string +---@param cb fun(err: nil|string) +local function touch(path, cb) + vim.loop.fs_open(path, "w", 420, function(err, fd) -- 0644 + if err then + cb(err) + else + local shortpath = path:gsub("^[^" .. fs.sep .. "]*" .. fs.sep, "") + vim.loop.fs_write(fd, shortpath, nil, function(err2) + if err2 then + cb(err2) + else + vim.loop.fs_close(fd, cb) + end + end) + end + end) +end + +---@param filepath string +---@return boolean +local function exists(filepath) + local stat = vim.loop.fs_stat(filepath) + return stat ~= nil and stat.type ~= nil +end + +local TmpDir = {} + +TmpDir.new = function() + local path = await(vim.loop.fs_mkdtemp, 2, "oil_test_XXXXXXXXX") + return setmetatable({ path = path }, { + __index = TmpDir, + }) +end + +---@param paths string[] +function TmpDir:create(paths) + for _, path in ipairs(paths) do + local pieces = vim.split(path, fs.sep) + local partial_path = self.path + for i, piece in ipairs(pieces) do + partial_path = fs.join(partial_path, piece) + if i == #pieces and not vim.endswith(partial_path, fs.sep) then + await(touch, 2, partial_path) + elseif not exists(partial_path) then + vim.loop.fs_mkdir(partial_path, 493) + end + end + end +end + +---@param filepath string +---@return string? +local read_file = function(filepath) + local fd = vim.loop.fs_open(filepath, "r", 420) + if not fd then + return nil + end + local stat = vim.loop.fs_fstat(fd) + local content = vim.loop.fs_read(fd, stat.size) + vim.loop.fs_close(fd) + return content +end + +---@param dir string +---@param cb fun(err: nil|string, entry: {type: oil.EntryType, name: string, root: string} +local function walk(dir) + local ret = {} + for name, type in vim.fs.dir(dir) do + table.insert(ret, { + name = name, + type = type, + root = dir, + }) + if type == "directory" then + vim.list_extend(ret, walk(fs.join(dir, name))) + end + end + return ret +end + +---@param paths table +local assert_fs = function(root, paths) + local unlisted_dirs = {} + for k in pairs(paths) do + local pieces = vim.split(k, "/") + local partial_path = "" + for i, piece in ipairs(pieces) do + partial_path = fs.join(partial_path, piece) .. "/" + if i ~= #pieces then + unlisted_dirs[partial_path:sub(2)] = true + end + end + end + for k in pairs(unlisted_dirs) do + paths[k] = true + end + + local entries = walk(root) + for _, entry in ipairs(entries) do + local fullpath = fs.join(entry.root, entry.name) + local shortpath = fullpath:sub(root:len() + 2) + if entry.type == "directory" then + shortpath = shortpath .. "/" + end + local expected_content = paths[shortpath] + paths[shortpath] = nil + assert.truthy(expected_content, string.format("Unexpected entry '%s'", shortpath)) + if entry.type == "file" then + local data = read_file(fullpath) + assert.equals( + expected_content, + data, + string.format( + "File '%s' expected content '%s' received '%s'", + shortpath, + expected_content, + data + ) + ) + end + end + + for k, v in pairs(paths) do + assert.falsy( + k, + string.format( + "Expected %s '%s', but it was not found", + v == true and "directory" or "file", + k + ) + ) + end +end + +---@param paths table +function TmpDir:assert_fs(paths) + a.util.scheduler() + assert_fs(self.path, paths) +end + +function TmpDir:dispose() + await(fs.recursive_delete, 3, "directory", self.path) +end + +a.describe("files adapter", function() + local tmpdir + a.before_each(function() + tmpdir = TmpDir.new() + end) + a.after_each(function() + if tmpdir then + tmpdir:dispose() + a.util.scheduler() + tmpdir = nil + end + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + cache.clear_everything() + end) + + a.it("tmpdir creates files and asserts they exist", function() + tmpdir:create({ "a.txt", "foo/b.txt", "foo/c.txt", "bar/" }) + tmpdir:assert_fs({ + ["a.txt"] = "a.txt", + ["foo/b.txt"] = "foo/b.txt", + ["foo/c.txt"] = "foo/c.txt", + ["bar/"] = true, + }) + end) + + a.it("Creates files", function() + local err = a.wrap(files.perform_action, 2)({ + url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "a.txt", + entry_type = "file", + type = "create", + }) + assert.is_nil(err) + tmpdir:assert_fs({ + ["a.txt"] = "", + }) + end) + + a.it("Creates directories", function() + local err = a.wrap(files.perform_action, 2)({ + url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "a", + entry_type = "directory", + type = "create", + }) + assert.is_nil(err) + tmpdir:assert_fs({ + ["a/"] = true, + }) + end) + + a.it("Deletes files", function() + tmpdir:create({ "a.txt" }) + a.util.scheduler() + local url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "a.txt" + local err = a.wrap(files.perform_action, 2)({ + url = url, + entry_type = "file", + type = "delete", + }) + assert.is_nil(err) + tmpdir:assert_fs({}) + end) + + a.it("Deletes directories", function() + tmpdir:create({ "a/" }) + local url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "a" + local err = a.wrap(files.perform_action, 2)({ + url = url, + entry_type = "directory", + type = "delete", + }) + assert.is_nil(err) + tmpdir:assert_fs({}) + end) + + a.it("Moves files", function() + tmpdir:create({ "a.txt" }) + a.util.scheduler() + local src_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "a.txt" + local dest_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "b.txt" + local err = a.wrap(files.perform_action, 2)({ + src_url = src_url, + dest_url = dest_url, + entry_type = "file", + type = "move", + }) + assert.is_nil(err) + tmpdir:assert_fs({ + ["b.txt"] = "a.txt", + }) + end) + + a.it("Moves directories", function() + tmpdir:create({ "a/a.txt" }) + a.util.scheduler() + local src_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "a" + local dest_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "b" + local err = a.wrap(files.perform_action, 2)({ + src_url = src_url, + dest_url = dest_url, + entry_type = "directory", + type = "move", + }) + assert.is_nil(err) + tmpdir:assert_fs({ + ["b/a.txt"] = "a/a.txt", + ["b/"] = true, + }) + end) + + a.it("Copies files", function() + tmpdir:create({ "a.txt" }) + a.util.scheduler() + local src_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "a.txt" + local dest_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "b.txt" + local err = a.wrap(files.perform_action, 2)({ + src_url = src_url, + dest_url = dest_url, + entry_type = "file", + type = "copy", + }) + assert.is_nil(err) + tmpdir:assert_fs({ + ["a.txt"] = "a.txt", + ["b.txt"] = "a.txt", + }) + end) + + a.it("Recursively copies directories", function() + tmpdir:create({ "a/a.txt" }) + a.util.scheduler() + local src_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "a" + local dest_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "b" + local err = a.wrap(files.perform_action, 2)({ + src_url = src_url, + dest_url = dest_url, + entry_type = "directory", + type = "copy", + }) + assert.is_nil(err) + tmpdir:assert_fs({ + ["b/a.txt"] = "a/a.txt", + ["b/"] = true, + ["a/a.txt"] = "a/a.txt", + ["a/"] = true, + }) + end) +end) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..1251e2e --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,11 @@ +vim.cmd([[set runtimepath+=.]]) + +vim.o.swapfile = false +vim.bo.swapfile = false +require("oil").setup({ + columms = {}, + adapters = { + ["oil-test://"] = "test", + }, + trash = false, +}) diff --git a/tests/mutator_spec.lua b/tests/mutator_spec.lua new file mode 100644 index 0000000..55077a3 --- /dev/null +++ b/tests/mutator_spec.lua @@ -0,0 +1,540 @@ +require("plenary.async").tests.add_to_env() +local FIELD = require("oil.constants").FIELD +local view = require("oil.view") +local cache = require("oil.cache") +local mutator = require("oil.mutator") +local parser = require("oil.mutator.parser") +local test_adapter = require("oil.adapters.test") +local util = require("oil.util") + +local function set_lines(bufnr, lines) + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) +end + +a.describe("mutator", function() + after_each(function() + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + cache.clear_everything() + end) + + describe("parser", function() + it("detects new files", function() + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, { + "a.txt", + }) + local diffs = parser.parse(bufnr) + assert.are.same({ { entry_type = "file", name = "a.txt", type = "new" } }, diffs) + end) + + it("detects new directories", function() + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, { + "foo/", + }) + local diffs = parser.parse(bufnr) + assert.are.same({ { entry_type = "directory", name = "foo", type = "new" } }, diffs) + end) + + it("detects deleted files", function() + local file = cache.create_and_store_entry("oil-test:///foo/", "a.txt", "file") + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, {}) + local diffs = parser.parse(bufnr) + assert.are.same({ + { name = "a.txt", type = "delete", id = file[FIELD.id] }, + }, diffs) + end) + + it("detects deleted directories", function() + local dir = cache.create_and_store_entry("oil-test:///foo/", "bar", "directory") + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, {}) + local diffs = parser.parse(bufnr) + assert.are.same({ + { name = "bar", type = "delete", id = dir[FIELD.id] }, + }, diffs) + end) + + it("ignores empty lines", function() + local file = cache.create_and_store_entry("oil-test:///foo/", "a.txt", "file") + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + local cols = view.format_entry_cols(file, {}, {}, test_adapter) + local lines = util.render_table({ cols }, {}) + table.insert(lines, "") + table.insert(lines, " ") + set_lines(bufnr, lines) + local diffs = parser.parse(bufnr) + assert.are.same({}, diffs) + end) + + it("errors on missing filename", function() + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, { + "/008", + }) + local _, errors = parser.parse(bufnr) + assert.are_same({ + { + message = "Malformed ID at start of line", + lnum = 0, + col = 0, + }, + }, errors) + end) + + it("errors on empty dirname", function() + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, { + "/008 /", + }) + local _, errors = parser.parse(bufnr) + assert.are.same({ + { + message = "No filename found", + lnum = 1, + col = 0, + }, + }, errors) + end) + + it("ignores new dirs with empty name", function() + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, { + "/", + }) + local diffs = parser.parse(bufnr) + assert.are.same({}, diffs) + end) + + it("parses a rename as a delete + new", function() + local file = cache.create_and_store_entry("oil-test:///foo/", "a.txt", "file") + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, { + string.format("/%d b.txt", file[FIELD.id]), + }) + local diffs = parser.parse(bufnr) + assert.are.same({ + { type = "new", id = file[FIELD.id], name = "b.txt", entry_type = "file" }, + { type = "delete", id = file[FIELD.id], name = "a.txt" }, + }, diffs) + end) + + it("detects renamed files that conflict", function() + local afile = cache.create_and_store_entry("oil-test:///foo/", "a.txt", "file") + local bfile = cache.create_and_store_entry("oil-test:///foo/", "b.txt", "file") + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + set_lines(bufnr, { + string.format("/%d a.txt", bfile[FIELD.id]), + string.format("/%d b.txt", afile[FIELD.id]), + }) + local diffs = parser.parse(bufnr) + local first_two = { diffs[1], diffs[2] } + local last_two = { diffs[3], diffs[4] } + table.sort(first_two, function(a, b) + return a.id < b.id + end) + table.sort(last_two, function(a, b) + return a.id < b.id + end) + assert.are.same({ + { name = "b.txt", type = "new", id = afile[FIELD.id], entry_type = "file" }, + { name = "a.txt", type = "new", id = bfile[FIELD.id], entry_type = "file" }, + }, first_two) + assert.are.same({ + { name = "a.txt", type = "delete", id = afile[FIELD.id] }, + { name = "b.txt", type = "delete", id = bfile[FIELD.id] }, + }, last_two) + end) + end) + + describe("build actions", function() + it("empty diffs produce no actions", function() + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + local actions = mutator.create_actions_from_diffs({ + [bufnr] = {}, + }) + assert.are.same({}, actions) + end) + + it("constructs CREATE actions", function() + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + local diffs = { + { type = "new", name = "a.txt", entry_type = "file" }, + } + local actions = mutator.create_actions_from_diffs({ + [bufnr] = diffs, + }) + assert.are.same({ + { + type = "create", + entry_type = "file", + url = "oil-test:///foo/a.txt", + }, + }, actions) + end) + + it("constructs DELETE actions", function() + local file = cache.create_and_store_entry("oil-test:///foo/", "a.txt", "file") + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + local diffs = { + { type = "delete", name = "a.txt", id = file[FIELD.id] }, + } + local actions = mutator.create_actions_from_diffs({ + [bufnr] = diffs, + }) + assert.are.same({ + { + type = "delete", + entry_type = "file", + url = "oil-test:///foo/a.txt", + }, + }, actions) + end) + + it("constructs COPY actions", function() + local file = cache.create_and_store_entry("oil-test:///foo/", "a.txt", "file") + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + local diffs = { + { type = "new", name = "b.txt", entry_type = "file", id = file[FIELD.id] }, + } + local actions = mutator.create_actions_from_diffs({ + [bufnr] = diffs, + }) + assert.are.same({ + { + type = "copy", + entry_type = "file", + src_url = "oil-test:///foo/a.txt", + dest_url = "oil-test:///foo/b.txt", + }, + }, actions) + end) + + it("constructs MOVE actions", function() + local file = cache.create_and_store_entry("oil-test:///foo/", "a.txt", "file") + vim.cmd.edit({ args = { "oil-test:///foo/" } }) + local bufnr = vim.api.nvim_get_current_buf() + local diffs = { + { type = "delete", name = "a.txt", id = file[FIELD.id] }, + { type = "new", name = "b.txt", entry_type = "file", id = file[FIELD.id] }, + } + local actions = mutator.create_actions_from_diffs({ + [bufnr] = diffs, + }) + assert.are.same({ + { + type = "move", + entry_type = "file", + src_url = "oil-test:///foo/a.txt", + dest_url = "oil-test:///foo/b.txt", + }, + }, actions) + end) + + it("correctly orders MOVE + CREATE", function() + local file = cache.create_and_store_entry("oil-test:///", "a.txt", "file") + vim.cmd.edit({ args = { "oil-test:///" } }) + local bufnr = vim.api.nvim_get_current_buf() + local diffs = { + { type = "delete", name = "a.txt", id = file[FIELD.id] }, + { type = "new", name = "b.txt", entry_type = "file", id = file[FIELD.id] }, + { type = "new", name = "a.txt", entry_type = "file" }, + } + local actions = mutator.create_actions_from_diffs({ + [bufnr] = diffs, + }) + assert.are.same({ + { + type = "move", + entry_type = "file", + src_url = "oil-test:///a.txt", + dest_url = "oil-test:///b.txt", + }, + { + type = "create", + entry_type = "file", + url = "oil-test:///a.txt", + }, + }, actions) + end) + + it("resolves MOVE loops", function() + local afile = cache.create_and_store_entry("oil-test:///", "a.txt", "file") + local bfile = cache.create_and_store_entry("oil-test:///", "b.txt", "file") + vim.cmd.edit({ args = { "oil-test:///" } }) + local bufnr = vim.api.nvim_get_current_buf() + local diffs = { + { type = "delete", name = "a.txt", id = afile[FIELD.id] }, + { type = "new", name = "b.txt", entry_type = "file", id = afile[FIELD.id] }, + { type = "delete", name = "b.txt", id = bfile[FIELD.id] }, + { type = "new", name = "a.txt", entry_type = "file", id = bfile[FIELD.id] }, + } + math.randomseed(2983982) + local actions = mutator.create_actions_from_diffs({ + [bufnr] = diffs, + }) + local tmp_url = "oil-test:///a.txt__oil_tmp_510852" + assert.are.same({ + { + type = "move", + entry_type = "file", + src_url = "oil-test:///a.txt", + dest_url = tmp_url, + }, + { + type = "move", + entry_type = "file", + src_url = "oil-test:///b.txt", + dest_url = "oil-test:///a.txt", + }, + { + type = "move", + entry_type = "file", + src_url = tmp_url, + dest_url = "oil-test:///b.txt", + }, + }, actions) + end) + end) + + describe("order actions", function() + it("Creates files inside dir before move", function() + local move = { + type = "move", + src_url = "oil-test:///a", + dest_url = "oil-test:///b", + entry_type = "directory", + } + local create = { type = "create", url = "oil-test:///a/hi.txt", entry_type = "file" } + local actions = { move, create } + local ordered_actions = mutator.enforce_action_order(actions) + assert.are.same({ create, move }, ordered_actions) + end) + + it("Handles parent child move ordering", function() + -- move parent into a child and child OUT of parent + -- MOVE /a/b -> /b + -- MOVE /a -> /b/a + local move1 = { + type = "move", + src_url = "oil-test:///a/b", + dest_url = "oil-test:///b", + entry_type = "directory", + } + local move2 = { + type = "move", + src_url = "oil-test:///a", + dest_url = "oil-test:///b/a", + entry_type = "directory", + } + local actions = { move2, move1 } + local ordered_actions = mutator.enforce_action_order(actions) + assert.are.same({ move1, move2 }, ordered_actions) + end) + + it("Detects move directory loops", function() + local move = { + type = "move", + src_url = "oil-test:///a", + dest_url = "oil-test:///a/b", + entry_type = "directory", + } + assert.has_error(function() + mutator.enforce_action_order({ move }) + end) + end) + + it("Detects copy directory loops", function() + local move = { + type = "copy", + src_url = "oil-test:///a", + dest_url = "oil-test:///a/b", + entry_type = "directory", + } + assert.has_error(function() + mutator.enforce_action_order({ move }) + end) + end) + + it("Detects nested copy directory loops", function() + local move = { + type = "copy", + src_url = "oil-test:///a", + dest_url = "oil-test:///a/b/a", + entry_type = "directory", + } + assert.has_error(function() + mutator.enforce_action_order({ move }) + end) + end) + + describe("change", function() + it("applies CHANGE after CREATE", function() + local create = { type = "create", url = "oil-test:///a/hi.txt", entry_type = "file" } + local change = { + type = "change", + url = "oil-test:///a/hi.txt", + entry_type = "file", + column = "TEST", + value = "TEST", + } + local actions = { change, create } + local ordered_actions = mutator.enforce_action_order(actions) + assert.are.same({ create, change }, ordered_actions) + end) + + it("applies CHANGE after COPY src", function() + local copy = { + type = "copy", + src_url = "oil-test:///a/hi.txt", + dest_url = "oil-test:///b.txt", + entry_type = "file", + } + local change = { + type = "change", + url = "oil-test:///a/hi.txt", + entry_type = "file", + column = "TEST", + value = "TEST", + } + local actions = { change, copy } + local ordered_actions = mutator.enforce_action_order(actions) + assert.are.same({ copy, change }, ordered_actions) + end) + + it("applies CHANGE after COPY dest", function() + local copy = { + type = "copy", + src_url = "oil-test:///b.txt", + dest_url = "oil-test:///a/hi.txt", + entry_type = "file", + } + local change = { + type = "change", + url = "oil-test:///a/hi.txt", + entry_type = "file", + column = "TEST", + value = "TEST", + } + local actions = { change, copy } + local ordered_actions = mutator.enforce_action_order(actions) + assert.are.same({ copy, change }, ordered_actions) + end) + + it("applies CHANGE after MOVE dest", function() + local move = { + type = "move", + src_url = "oil-test:///b.txt", + dest_url = "oil-test:///a/hi.txt", + entry_type = "file", + } + local change = { + type = "change", + url = "oil-test:///a/hi.txt", + entry_type = "file", + column = "TEST", + value = "TEST", + } + local actions = { change, move } + local ordered_actions = mutator.enforce_action_order(actions) + assert.are.same({ move, change }, ordered_actions) + end) + end) + end) + + a.describe("perform actions", function() + a.it("creates new entries", function() + local actions = { + { type = "create", url = "oil-test:///a.txt", entry_type = "file" }, + } + a.wrap(mutator.process_actions, 2)(actions) + local files = cache.list_url("oil-test:///") + assert.are.same({ + ["a.txt"] = { + [FIELD.id] = 1, + [FIELD.type] = "file", + [FIELD.name] = "a.txt", + }, + }, files) + end) + + a.it("deletes entries", function() + local file = cache.create_and_store_entry("oil-test:///", "a.txt", "file") + local actions = { + { type = "delete", url = "oil-test:///a.txt", entry_type = "file" }, + } + a.wrap(mutator.process_actions, 2)(actions) + local files = cache.list_url("oil-test:///") + assert.are.same({}, files) + assert.is_nil(cache.get_entry_by_id(file[FIELD.id])) + assert.has_error(function() + cache.get_parent_url(file[FIELD.id]) + end) + end) + + a.it("moves entries", function() + local file = cache.create_and_store_entry("oil-test:///", "a.txt", "file") + local actions = { + { + type = "move", + src_url = "oil-test:///a.txt", + dest_url = "oil-test:///b.txt", + entry_type = "file", + }, + } + a.wrap(mutator.process_actions, 2)(actions) + local files = cache.list_url("oil-test:///") + local new_entry = { + [FIELD.id] = file[FIELD.id], + [FIELD.type] = "file", + [FIELD.name] = "b.txt", + } + assert.are.same({ + ["b.txt"] = new_entry, + }, files) + assert.are.same(new_entry, cache.get_entry_by_id(file[FIELD.id])) + assert.equals("oil-test:///", cache.get_parent_url(file[FIELD.id])) + end) + + a.it("copies entries", function() + local file = cache.create_and_store_entry("oil-test:///", "a.txt", "file") + local actions = { + { + type = "copy", + src_url = "oil-test:///a.txt", + dest_url = "oil-test:///b.txt", + entry_type = "file", + }, + } + a.wrap(mutator.process_actions, 2)(actions) + local files = cache.list_url("oil-test:///") + local new_entry = { + [FIELD.id] = file[FIELD.id] + 1, + [FIELD.type] = "file", + [FIELD.name] = "b.txt", + } + assert.are.same({ + ["a.txt"] = file, + ["b.txt"] = new_entry, + }, files) + end) + end) +end) diff --git a/tests/path_spec.lua b/tests/path_spec.lua new file mode 100644 index 0000000..a92a1ca --- /dev/null +++ b/tests/path_spec.lua @@ -0,0 +1,32 @@ +local pathutil = require("oil.pathutil") +describe("pathutil", function() + it("calculates parent path", function() + local cases = { + { "/foo/bar", "/foo/" }, + { "/foo/bar/", "/foo/" }, + { "/", "/" }, + { "", "" }, + { "foo/bar/", "foo/" }, + { "foo", "" }, + } + for _, case in ipairs(cases) do + local input, expected = unpack(case) + local output = pathutil.parent(input) + assert.equals(expected, output, string.format('Parent path "%s" failed', input)) + end + end) + + it("calculates basename", function() + local cases = { + { "/foo/bar", "bar" }, + { "/foo/bar/", "bar" }, + { "/", nil }, + { "", nil }, + } + for _, case in ipairs(cases) do + local input, expected = unpack(case) + local output = pathutil.basename(input) + assert.equals(expected, output, string.format('Basename "%s" failed', input)) + end + end) +end) diff --git a/tests/url_spec.lua b/tests/url_spec.lua new file mode 100644 index 0000000..6a1861a --- /dev/null +++ b/tests/url_spec.lua @@ -0,0 +1,24 @@ +local oil = require("oil") +local util = require("oil.util") +describe("url", function() + it("get_url_for_path", function() + local cases = { + { "", "oil://" .. util.addslash(vim.fn.getcwd()) }, + { "/foo/bar.txt", "oil:///foo/", "bar.txt" }, + { "oil:///foo/bar.txt", "oil:///foo/", "bar.txt" }, + { "oil:///", "oil:///" }, + { "scp://user@hostname:8888//bar.txt", "oil-ssh://user@hostname:8888//", "bar.txt" }, + { "oil-ssh://user@hostname:8888//", "oil-ssh://user@hostname:8888//" }, + } + for _, case in ipairs(cases) do + local input, expected, expected_basename = unpack(case) + local output, basename = oil.get_buffer_parent_url(input) + assert.equals(expected, output, string.format('Parent url for path "%s" failed', input)) + assert.equals( + expected_basename, + basename, + string.format('Basename for path "%s" failed', input) + ) + end + end) +end)