feat: first draft

This commit is contained in:
Steven Arcangeli 2022-12-15 02:24:27 -08:00
parent bf2dfb970d
commit fefd6ad5e4
48 changed files with 7201 additions and 1 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
layout python

177
.github/generate.py vendored Executable file
View file

@ -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"^<!-- API -->$",
r"^<!-- /API -->$",
lines,
)
def update_readme_toc():
toc = ["\n"] + generate_md_toc(README, max_level=1) + ["\n"]
replace_section(
README,
r"^<!-- TOC -->$",
r"^<!-- /TOC -->$",
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<string, string>", "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()

31
.github/main.py vendored Executable file
View file

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

1
.github/nvim_doc_tools vendored Submodule

@ -0,0 +1 @@
Subproject commit d146f2b7e72892b748e21d40a175267ce2ac1b7b

5
.github/pre-commit vendored Executable file
View file

@ -0,0 +1,5 @@
#!/bin/bash
set -e
luacheck lua tests
stylua --check .

12
.github/workflows/install_nvim.sh vendored Normal file
View file

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

54
.github/workflows/tests.yml vendored Normal file
View file

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

36
.github/workflows/update-docs.yml vendored Normal file
View file

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

2
.gitignore vendored
View file

@ -39,3 +39,5 @@ luac.out
*.x86_64
*.hex
.direnv/
.testenv/

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule ".github/nvim_doc_tools"]
path = .github/nvim_doc_tools
url = https://github.com/stevearc/nvim_doc_tools

19
.luacheckrc Normal file
View file

@ -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",
}

3
.stylua.toml Normal file
View file

@ -0,0 +1,3 @@
column_width = 100
indent_type = "Spaces"
indent_width = 2

332
README.md
View file

@ -1 +1,333 @@
# 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
<!-- TOC -->
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Options](#options)
- [Adapters](#adapters)
- [API](#api)
- [FAQ](#faq)
<!-- /TOC -->
## 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
<details>
<summary>Packer</summary>
```lua
require('packer').startup(function()
use {
'stevearc/oil.nvim',
config = function() require('oil').setup() end
}
end)
```
</details>
<details>
<summary>Paq</summary>
```lua
require "paq" {
{'stevearc/oil.nvim'};
}
```
</details>
<details>
<summary>vim-plug</summary>
```vim
Plug 'stevearc/oil.nvim'
```
</details>
<details>
<summary>dein</summary>
```vim
call dein#add('stevearc/oil.nvim')
```
</details>
<details>
<summary>Pathogen</summary>
```sh
git clone --depth=1 https://github.com/stevearc/oil.nvim.git ~/.vim/bundle/
```
</details>
<details>
<summary>Neovim native package</summary>
```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
```
</details>
## Quick start
Add the following to your init.lua
```lua
require("oil").setup()
```
Then open a directory with `nvim .`. Use `<CR>` 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.<name>",
-- it will use the mapping at require("oil.action").<name>
-- Set to `false` to remove a keymap
keymaps = {
["g?"] = "actions.show_help",
["<CR>"] = "actions.select",
["<C-s>"] = "actions.select_vsplit",
["<C-h>"] = "actions.select_split",
["<C-p>"] = "actions.preview",
["<C-c>"] = "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
<!-- 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` | |
<!-- /API -->
## 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.

251
doc/oil.txt Normal file
View file

@ -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.<name>",
-- it will use the mapping at require("oil.action").<name>
-- Set to `false` to remove a keymap
keymaps = {
["g?"] = "actions.show_help",
["<CR>"] = "actions.select",
["<C-s>"] = "actions.select_vsplit",
["<C-h>"] = "actions.select_split",
["<C-p>"] = "actions.preview",
["<C-c>"] = "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<string, string>` 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:

37
doc/tags Normal file
View file

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

87
lua/oil/actions.lua Normal file
View file

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

406
lua/oil/adapters/files.lua Normal file
View file

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

View file

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

454
lua/oil/adapters/ssh.lua Normal file
View file

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

View file

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

62
lua/oil/adapters/test.lua Normal file
View file

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

183
lua/oil/cache.lua Normal file
View file

@ -0,0 +1,183 @@
local util = require("oil.util")
local FIELD = require("oil.constants").FIELD
local M = {}
local next_id = 1
-- Map<url, Map<entry name, oil.InternalEntry>>
local url_directory = {}
---@type table<integer, oil.InternalEntry>
local entries_by_id = {}
---@type table<integer, string>
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

238
lua/oil/columns.lua Normal file
View file

@ -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<string, fun(parent_url:L string, entry: oil.InternalEntry, cb: fn(err: nil|string))>
---@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

154
lua/oil/config.lua Normal file
View file

@ -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.<name>",
-- it will use the mapping at require("oil.action").<name>
-- Set to `false` to remove a keymap
keymaps = {
["g?"] = "actions.show_help",
["<CR>"] = "actions.select",
["<C-s>"] = "actions.select_vsplit",
["<C-h>"] = "actions.select_split",
["<C-p>"] = "actions.preview",
["<C-c>"] = "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

10
lua/oil/constants.lua Normal file
View file

@ -0,0 +1,10 @@
local M = {}
M.FIELD = {
id = 1,
name = 2,
type = 3,
meta = 4,
}
return M

256
lua/oil/fs.lua Normal file
View file

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

582
lua/oil/init.lua Normal file
View file

@ -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<string, boolean>
---@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

100
lua/oil/keymap_util.lua Normal file
View file

@ -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", "<cmd>close<CR>", { buffer = bufnr })
vim.keymap.set("n", "<c-c>", "<cmd>close<CR>", { 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

61
lua/oil/loading.lua Normal file
View file

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

View file

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

507
lua/oil/mutator/init.lua Normal file
View file

@ -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<integer, oil.Diff[]>
---@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

225
lua/oil/mutator/parser.lua Normal file
View file

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

130
lua/oil/mutator/preview.lua Normal file
View file

@ -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", "<Esc>", cancel, { buffer = bufnr })
vim.keymap.set("n", "<C-c>", cancel, { buffer = bufnr })
vim.keymap.set("n", "O", confirm, { buffer = bufnr })
vim.keymap.set("n", "o", confirm, { buffer = bufnr })
end)
return M

View file

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

153
lua/oil/mutator/trie.lua Normal file
View file

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

37
lua/oil/pathutil.lua Normal file
View file

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

140
lua/oil/repl_layout.lua Normal file
View file

@ -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", "<Esc>", cancel, { buffer = input_bufnr })
vim.keymap.set({ "n", "i" }, "<C-c>", cancel, { buffer = input_bufnr })
vim.keymap.set({ "n", "i" }, "<CR>", 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

40
lua/oil/shell.lua Normal file
View file

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

500
lua/oil/util.lua Normal file
View file

@ -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("<ESC>", 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

430
lua/oil/view.lua Normal file
View file

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

24
run_tests.sh Executable file
View file

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

7
syntax/oil.vim Normal file
View file

@ -0,0 +1,7 @@
if exists("b:current_syntax")
finish
endif
syn match oilId /^\/\d* / conceal
let b:current_syntax = "oil"

11
syntax/oil_preview.vim Normal file
View file

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

311
tests/files_spec.lua Normal file
View file

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

11
tests/minimal_init.lua Normal file
View file

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

540
tests/mutator_spec.lua Normal file
View file

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

32
tests/path_spec.lua Normal file
View file

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

24
tests/url_spec.lua Normal file
View file

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