diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bdcc4c3..2b62853 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - stevearc-* pull_request: branches: - master @@ -52,8 +53,8 @@ jobs: include: - nvim_tag: v0.8.3 - nvim_tag: v0.9.4 - - nvim_tag: v0.10.0 - nvim_tag: v0.10.4 + - nvim_tag: v0.11.0 name: Run tests runs-on: ubuntu-22.04 diff --git a/README.md b/README.md index ec3355b..bf12bed 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ https://user-images.githubusercontent.com/506791/209727111-6b4a11f4-634a-4efa-94 - [Options](#options) - [Adapters](#adapters) - [Recipes](#recipes) +- [Third-party extensions](#third-party-extensions) - [API](#api) - [FAQ](#faq) @@ -21,7 +22,7 @@ https://user-images.githubusercontent.com/506791/209727111-6b4a11f4-634a-4efa-94 - Neovim 0.8+ - Icon provider plugin (optional) - - [mini.icons](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-icons.md) for file and folder icons + - [mini.icons](https://github.com/nvim-mini/mini.nvim/blob/main/readmes/mini-icons.md) for file and folder icons - [nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) for file icons ## Installation @@ -38,7 +39,7 @@ oil.nvim supports all the usual plugin managers ---@type oil.SetupOpts opts = {}, -- Optional dependencies - dependencies = { { "echasnovski/mini.icons", opts = {} } }, + dependencies = { { "nvim-mini/mini.icons", opts = {} } }, -- dependencies = { "nvim-tree/nvim-web-devicons" }, -- use if you prefer nvim-web-devicons -- Lazy loading is not recommended because it is very tricky to make it work correctly in all situations. lazy = false, @@ -203,7 +204,7 @@ require("oil").setup({ ["-"] = { "actions.parent", mode = "n" }, ["_"] = { "actions.open_cwd", mode = "n" }, ["`"] = { "actions.cd", mode = "n" }, - ["~"] = { "actions.cd", opts = { scope = "tab" }, mode = "n" }, + ["g~"] = { "actions.cd", opts = { scope = "tab" }, mode = "n" }, ["gs"] = { "actions.change_sort", mode = "n" }, ["gx"] = "actions.open_external", ["g."] = { "actions.toggle_hidden", mode = "n" }, @@ -241,6 +242,8 @@ require("oil").setup({ }, -- Extra arguments to pass to SCP when moving/copying files over SSH extra_scp_args = {}, + -- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3 + extra_s3_args = {}, -- EXPERIMENTAL support for performing file operations with git git = { -- Return true to automatically git add/mv/rm files @@ -261,7 +264,7 @@ require("oil").setup({ -- max_width and max_height can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) max_width = 0, max_height = 0, - border = "rounded", + border = nil, win_options = { winblend = 0, }, @@ -306,7 +309,7 @@ require("oil").setup({ min_height = { 5, 0.1 }, -- optionally define an integer/float for the exact height of the preview window height = nil, - border = "rounded", + border = nil, win_options = { winblend = 0, }, @@ -319,7 +322,7 @@ require("oil").setup({ max_height = { 10, 0.9 }, min_height = { 5, 0.1 }, height = nil, - border = "rounded", + border = nil, minimized_border = "none", win_options = { winblend = 0, @@ -327,11 +330,11 @@ require("oil").setup({ }, -- Configuration for the floating SSH window ssh = { - border = "rounded", + border = nil, }, -- Configuration for the floating keymaps help window keymaps_help = { - border = "rounded", + border = nil, }, }) ``` @@ -354,12 +357,30 @@ This may look familiar. In fact, this is the same url format that netrw uses. Note that at the moment the ssh adapter does not support Windows machines, and it requires the server to have a `/bin/sh` binary as well as standard unix commands (`ls`, `rm`, `mv`, `mkdir`, `chmod`, `cp`, `touch`, `ln`, `echo`). +### S3 + +This adapter allows you to browse files stored in aws s3. To use it, make sure `aws` is setup correctly and then simply open a buffer using the following name template: + +``` +nvim oil-s3://[bucket]/[path] +``` + +Note that older versions of Neovim don't support numbers in the url, so for Neovim 0.11 and older the url starts with `oil-sss`. + ## Recipes - [Toggle file detail view](doc/recipes.md#toggle-file-detail-view) - [Show CWD in the winbar](doc/recipes.md#show-cwd-in-the-winbar) - [Hide gitignored files and show git tracked hidden files](doc/recipes.md#hide-gitignored-files-and-show-git-tracked-hidden-files) +## Third-party extensions + +These are plugins maintained by other authors that extend the functionality of oil.nvim. + +- [oil-git-status.nvim](https://github.com/refractalize/oil-git-status.nvim) - Shows git status of files in statuscolumn +- [oil-git.nvim](https://github.com/benomahony/oil-git.nvim) - Shows git status of files with colour and symbols +- [oil-lsp-diagnostics.nvim](https://github.com/JezerM/oil-lsp-diagnostics.nvim) - Shows LSP diagnostics indicator as virtual text + ## API @@ -373,7 +394,7 @@ Note that at the moment the ssh adapter does not support Windows machines, and i - [toggle_hidden()](doc/api.md#toggle_hidden) - [get_current_dir(bufnr)](doc/api.md#get_current_dirbufnr) - [open_float(dir, opts, cb)](doc/api.md#open_floatdir-opts-cb) -- [toggle_float(dir)](doc/api.md#toggle_floatdir) +- [toggle_float(dir, opts, cb)](doc/api.md#toggle_floatdir-opts-cb) - [open(dir, opts, cb)](doc/api.md#opendir-opts-cb) - [close(opts)](doc/api.md#closeopts) - [open_preview(opts, callback)](doc/api.md#open_previewopts-callback) @@ -400,7 +421,7 @@ Plus, I think it's pretty slick ;) - 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. (update: [mini.files](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-files.md) also offers this functionality) +- AND you want to perform cross-directory actions. AFAIK there is no other plugin that does this. (update: [mini.files](https://github.com/nvim-mini/mini.nvim/blob/main/readmes/mini-files.md) also offers this functionality) If you don't need those features specifically, check out the alternatives listed below @@ -416,7 +437,7 @@ If you don't need those features specifically, check out the alternatives listed **A:** -- [mini.files](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-files.md): A newer plugin that also supports cross-directory filesystem-as-buffer edits. It utilizes a unique column view. +- [mini.files](https://github.com/nvim-mini/mini.nvim/blob/main/readmes/mini-files.md): A newer plugin that also supports cross-directory filesystem-as-buffer edits. It utilizes a unique column view. - [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). diff --git a/doc/api.md b/doc/api.md index 3e6a3e6..24d4a50 100644 --- a/doc/api.md +++ b/doc/api.md @@ -11,7 +11,7 @@ - [toggle_hidden()](#toggle_hidden) - [get_current_dir(bufnr)](#get_current_dirbufnr) - [open_float(dir, opts, cb)](#open_floatdir-opts-cb) -- [toggle_float(dir)](#toggle_floatdir) +- [toggle_float(dir, opts, cb)](#toggle_floatdir-opts-cb) - [open(dir, opts, cb)](#opendir-opts-cb) - [close(opts)](#closeopts) - [open_preview(opts, callback)](#open_previewopts-callback) @@ -107,14 +107,20 @@ Open oil browser in a floating window | >>split | `nil\|"aboveleft"\|"belowright"\|"topleft"\|"botright"` | Split modifier | | cb | `nil\|fun()` | Called after the oil buffer is ready | -## toggle_float(dir) +## toggle_float(dir, opts, cb) -`toggle_float(dir)` \ +`toggle_float(dir, opts, cb)` \ Open oil browser in a floating window, or close it if open -| Param | Type | Desc | -| ----- | ------------- | ------------------------------------------------------------------------------------------- | -| dir | `nil\|string` | When nil, open the parent of the current buffer, or the cwd if current buffer is not a file | +| Param | Type | Desc | +| ------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| dir | `nil\|string` | When nil, open the parent of the current buffer, or the cwd if current buffer is not a file | +| opts | `nil\|oil.OpenOpts` | | +| >preview | `nil\|oil.OpenPreviewOpts` | When present, open the preview window after opening oil | +| >>vertical | `nil\|boolean` | Open the buffer in a vertical split | +| >>horizontal | `nil\|boolean` | Open the buffer in a horizontal split | +| >>split | `nil\|"aboveleft"\|"belowright"\|"topleft"\|"botright"` | Split modifier | +| cb | `nil\|fun()` | Called after the oil buffer is ready | ## open(dir, opts, cb) @@ -159,15 +165,16 @@ Preview the entry under the cursor in a split `select(opts, callback)` \ Select the entry under the cursor -| Param | Type | Desc | -| ----------- | ------------------------------------------------------- | ---------------------------------------------------- | -| opts | `nil\|oil.SelectOpts` | | -| >vertical | `nil\|boolean` | Open the buffer in a vertical split | -| >horizontal | `nil\|boolean` | Open the buffer in a horizontal split | -| >split | `nil\|"aboveleft"\|"belowright"\|"topleft"\|"botright"` | Split modifier | -| >tab | `nil\|boolean` | Open the buffer in a new tab | -| >close | `nil\|boolean` | Close the original oil buffer once selection is made | -| callback | `nil\|fun(err: nil\|string)` | Called once all entries have been opened | +| Param | Type | Desc | +| ----------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| opts | `nil\|oil.SelectOpts` | | +| >vertical | `nil\|boolean` | Open the buffer in a vertical split | +| >horizontal | `nil\|boolean` | Open the buffer in a horizontal split | +| >split | `nil\|"aboveleft"\|"belowright"\|"topleft"\|"botright"` | Split modifier | +| >tab | `nil\|boolean` | Open the buffer in a new tab | +| >close | `nil\|boolean` | Close the original oil buffer once selection is made | +| >handle_buffer_callback | `nil\|fun(buf_id: integer)` | If defined, all other buffer related options here would be ignored. This callback allows you to take over the process of opening the buffer yourself. | +| callback | `nil\|fun(err: nil\|string)` | Called once all entries have been opened | ## save(opts, cb) diff --git a/doc/oil.txt b/doc/oil.txt index fea9bfd..8753f87 100644 --- a/doc/oil.txt +++ b/doc/oil.txt @@ -86,7 +86,7 @@ CONFIG *oil-confi ["-"] = { "actions.parent", mode = "n" }, ["_"] = { "actions.open_cwd", mode = "n" }, ["`"] = { "actions.cd", mode = "n" }, - ["~"] = { "actions.cd", opts = { scope = "tab" }, mode = "n" }, + ["g~"] = { "actions.cd", opts = { scope = "tab" }, mode = "n" }, ["gs"] = { "actions.change_sort", mode = "n" }, ["gx"] = "actions.open_external", ["g."] = { "actions.toggle_hidden", mode = "n" }, @@ -124,6 +124,8 @@ CONFIG *oil-confi }, -- Extra arguments to pass to SCP when moving/copying files over SSH extra_scp_args = {}, + -- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3 + extra_s3_args = {}, -- EXPERIMENTAL support for performing file operations with git git = { -- Return true to automatically git add/mv/rm files @@ -144,7 +146,7 @@ CONFIG *oil-confi -- max_width and max_height can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) max_width = 0, max_height = 0, - border = "rounded", + border = nil, win_options = { winblend = 0, }, @@ -189,7 +191,7 @@ CONFIG *oil-confi min_height = { 5, 0.1 }, -- optionally define an integer/float for the exact height of the preview window height = nil, - border = "rounded", + border = nil, win_options = { winblend = 0, }, @@ -202,7 +204,7 @@ CONFIG *oil-confi max_height = { 10, 0.9 }, min_height = { 5, 0.1 }, height = nil, - border = "rounded", + border = nil, minimized_border = "none", win_options = { winblend = 0, @@ -210,11 +212,11 @@ CONFIG *oil-confi }, -- Configuration for the floating SSH window ssh = { - border = "rounded", + border = nil, }, -- Configuration for the floating keymaps help window keymaps_help = { - border = "rounded", + border = nil, }, }) < @@ -319,12 +321,20 @@ open_float({dir}, {opts}, {cb}) *oil.open_floa plit modifier {cb} `nil|fun()` Called after the oil buffer is ready -toggle_float({dir}) *oil.toggle_float* +toggle_float({dir}, {opts}, {cb}) *oil.toggle_float* Open oil browser in a floating window, or close it if open Parameters: - {dir} `nil|string` When nil, open the parent of the current buffer, or the - cwd if current buffer is not a file + {dir} `nil|string` When nil, open the parent of the current buffer, or + the cwd if current buffer is not a file + {opts} `nil|oil.OpenOpts` + {preview} `nil|oil.OpenPreviewOpts` When present, open the preview + window after opening oil + {vertical} `nil|boolean` Open the buffer in a vertical split + {horizontal} `nil|boolean` Open the buffer in a horizontal split + {split} `nil|"aboveleft"|"belowright"|"topleft"|"botright"` S + plit modifier + {cb} `nil|fun()` Called after the oil buffer is ready open({dir}, {opts}, {cb}) *oil.open* Open oil browser for a directory @@ -373,6 +383,10 @@ select({opts}, {callback}) *oil.selec {tab} `nil|boolean` Open the buffer in a new tab {close} `nil|boolean` Close the original oil buffer once selection is made + {handle_buffer_callback} `nil|fun(buf_id: integer)` If defined, all + other buffer related options here would be ignored. This + callback allows you to take over the process of opening + the buffer yourself. {callback} `nil|fun(err: nil|string)` Called once all entries have been opened @@ -408,6 +422,7 @@ type *column-typ Parameters: {highlight} `string|fun(value: string): string` Highlight group, or function that returns a highlight group + {align} `"left"|"center"|"right"` Text alignment within the column {icons} `table` Mapping of entry type to icon icon *column-icon* @@ -417,6 +432,7 @@ icon *column-ico Parameters: {highlight} `string|fun(value: string): string` Highlight group, or function that returns a highlight group + {align} `"left"|"center"|"right"` Text alignment within the column {default_file} `string` Fallback icon for files when nvim-web-devicons returns nil {directory} `string` Icon for directories @@ -424,13 +440,14 @@ icon *column-ico the icon size *column-size* - Adapters: files, ssh + Adapters: files, ssh, s3 Sortable: this column can be used in view_props.sort The size of the file Parameters: {highlight} `string|fun(value: string): string` Highlight group, or function that returns a highlight group + {align} `"left"|"center"|"right"` Text alignment within the column permissions *column-permissions* Adapters: files, ssh @@ -440,6 +457,7 @@ permissions *column-permission Parameters: {highlight} `string|fun(value: string): string` Highlight group, or function that returns a highlight group + {align} `"left"|"center"|"right"` Text alignment within the column ctime *column-ctime* Adapters: files @@ -449,6 +467,7 @@ ctime *column-ctim Parameters: {highlight} `string|fun(value: string): string` Highlight group, or function that returns a highlight group + {align} `"left"|"center"|"right"` Text alignment within the column {format} `string` Format string (see :help strftime) mtime *column-mtime* @@ -459,6 +478,7 @@ mtime *column-mtim Parameters: {highlight} `string|fun(value: string): string` Highlight group, or function that returns a highlight group + {align} `"left"|"center"|"right"` Text alignment within the column {format} `string` Format string (see :help strftime) atime *column-atime* @@ -469,16 +489,18 @@ atime *column-atim Parameters: {highlight} `string|fun(value: string): string` Highlight group, or function that returns a highlight group + {align} `"left"|"center"|"right"` Text alignment within the column {format} `string` Format string (see :help strftime) birthtime *column-birthtime* - Adapters: files + Adapters: files, s3 Sortable: this column can be used in view_props.sort The time the file was created Parameters: {highlight} `string|fun(value: string): string` Highlight group, or function that returns a highlight group + {align} `"left"|"center"|"right"` Text alignment within the column {format} `string` Format string (see :help strftime) -------------------------------------------------------------------------------- @@ -545,6 +567,9 @@ close *actions.clos Parameters: {exit_if_last_buf} `boolean` Exit vim if oil is closed as the last buffer +copy_to_system_clipboard *actions.copy_to_system_clipboard* + Copy the entry under the cursor to the system clipboard + open_cmdline *actions.open_cmdline* Open vim cmdline with current entry as an argument @@ -565,6 +590,12 @@ open_terminal *actions.open_termina parent *actions.parent* Navigate to the parent path +paste_from_system_clipboard *actions.paste_from_system_clipboard* + Paste the system clipboard into the current oil directory + + Parameters: + {delete_original} `boolean` Delete the original file after copying + preview *actions.preview* Open the entry under the cursor in a preview window, or close the preview window if already open @@ -578,6 +609,12 @@ preview *actions.previe preview_scroll_down *actions.preview_scroll_down* Scroll down in the preview window +preview_scroll_left *actions.preview_scroll_left* + Scroll left in the preview window + +preview_scroll_right *actions.preview_scroll_right* + Scroll right in the preview window + preview_scroll_up *actions.preview_scroll_up* Scroll up in the preview window @@ -631,6 +668,9 @@ yank_entry *actions.yank_entr -------------------------------------------------------------------------------- HIGHLIGHTS *oil-highlights* +OilEmpty *hl-OilEmpty* + Empty column values + OilHidden *hl-OilHidden* Hidden entry in an oil buffer diff --git a/lua/oil/actions.lua b/lua/oil/actions.lua index cef37c7..1c61999 100644 --- a/lua/oil/actions.lua +++ b/lua/oil/actions.lua @@ -136,6 +136,30 @@ M.preview_scroll_up = { end, } +M.preview_scroll_left = { + desc = "Scroll left in the preview window", + callback = function() + local winid = util.get_preview_win() + if winid then + vim.api.nvim_win_call(winid, function() + vim.cmd.normal({ "zH", bang = true }) + end) + end + end, +} + +M.preview_scroll_right = { + desc = "Scroll right in the preview window", + callback = function() + local winid = util.get_preview_win() + if winid then + vim.api.nvim_win_call(winid, function() + vim.cmd.normal({ "zL", bang = true }) + end) + end + end, +} + M.parent = { desc = "Navigate to the parent path", callback = oil.open, @@ -229,13 +253,24 @@ M.open_terminal = { assert(dir, "Oil buffer with files adapter must have current directory") local bufnr = vim.api.nvim_create_buf(false, true) vim.api.nvim_set_current_buf(bufnr) - vim.fn.termopen(vim.o.shell, { cwd = dir }) + if vim.fn.has("nvim-0.11") == 1 then + vim.fn.jobstart(vim.o.shell, { cwd = dir, term = true }) + else + ---@diagnostic disable-next-line: deprecated + vim.fn.termopen(vim.o.shell, { cwd = dir }) + end elseif adapter.name == "ssh" then local bufnr = vim.api.nvim_create_buf(false, true) vim.api.nvim_set_current_buf(bufnr) local url = require("oil.adapters.ssh").parse_url(bufname) local cmd = require("oil.adapters.ssh.connection").create_ssh_command(url) - local term_id = vim.fn.termopen(cmd) + local term_id + if vim.fn.has("nvim-0.11") == 1 then + term_id = vim.fn.jobstart(cmd, { term = true }) + else + ---@diagnostic disable-next-line: deprecated + term_id = vim.fn.termopen(cmd) + end if term_id then vim.api.nvim_chan_send(term_id, string.format("cd %s\n", url.path)) end @@ -418,6 +453,26 @@ M.copy_entry_filename = { end, } +M.copy_to_system_clipboard = { + desc = "Copy the entry under the cursor to the system clipboard", + callback = function() + require("oil.clipboard").copy_to_system_clipboard() + end, +} + +M.paste_from_system_clipboard = { + desc = "Paste the system clipboard into the current oil directory", + callback = function(opts) + require("oil.clipboard").paste_from_system_clipboard(opts and opts.delete_original) + end, + parameters = { + delete_original = { + type = "boolean", + desc = "Delete the original file after copying", + }, + }, +} + M.open_cmdline_dir = { desc = "Open vim cmdline with current directory as an argument", deprecated = true, diff --git a/lua/oil/adapters/files.lua b/lua/oil/adapters/files.lua index 40e82f5..9785c3a 100644 --- a/lua/oil/adapters/files.lua +++ b/lua/oil/adapters/files.lua @@ -6,7 +6,6 @@ local fs = require("oil.fs") local git = require("oil.git") local log = require("oil.log") local permissions = require("oil.adapters.files.permissions") -local trash = require("oil.adapters.files.trash") local util = require("oil.util") local uv = vim.uv or vim.loop @@ -183,7 +182,17 @@ for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do -- Replace placeholders with a pattern that matches non-space characters (e.g. %H -> %S+) -- and whitespace with a pattern that matches any amount of whitespace -- e.g. "%b %d %Y" -> "%S+%s+%S+%s+%S+" - pattern = fmt:gsub("%%.", "%%S+"):gsub("%s+", "%%s+") + pattern = fmt + :gsub("%%.", "%%S+") + :gsub("%s+", "%%s+") + -- escape `()[]` because those are special characters in Lua patterns + :gsub( + "%(", + "%%(" + ) + :gsub("%)", "%%)") + :gsub("%[", "%%[") + :gsub("%]", "%%]") else pattern = "%S+%s+%d+%s+%d%d:?%d%d" end @@ -267,7 +276,7 @@ M.normalize_url = function(url, callback) local norm_path = util.addslash(fs.os_to_posix_path(realpath)) callback(scheme .. norm_path) else - callback(realpath) + callback(vim.fn.fnamemodify(realpath, ":.")) end end) ) @@ -530,7 +539,7 @@ M.render_action = function(action) return string.format("DELETE %s", short_path) end elseif action.type == "move" or action.type == "copy" then - local dest_adapter = config.get_adapter_by_scheme(action.dest_url) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) if dest_adapter == M then local _, src_path = util.parse_url(action.src_url) assert(src_path) @@ -610,20 +619,12 @@ M.perform_action = function(action, cb) end if config.delete_to_trash then - if config.trash_command then - vim.notify_once( - "Oil now has native support for trash. Remove the `trash_command` from your config to try it out!", - vim.log.levels.WARN - ) - trash.recursive_delete(path, cb) - else - require("oil.adapters.trash").delete_to_trash(path, cb) - end + require("oil.adapters.trash").delete_to_trash(path, cb) else fs.recursive_delete(action.entry_type, path, cb) end elseif action.type == "move" then - local dest_adapter = config.get_adapter_by_scheme(action.dest_url) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) if dest_adapter == M then local _, src_path = util.parse_url(action.src_url) assert(src_path) @@ -641,7 +642,7 @@ M.perform_action = function(action, cb) 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) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) if dest_adapter == M then local _, src_path = util.parse_url(action.src_url) assert(src_path) diff --git a/lua/oil/adapters/files/trash.lua b/lua/oil/adapters/files/trash.lua deleted file mode 100644 index 5236f5b..0000000 --- a/lua/oil/adapters/files/trash.lua +++ /dev/null @@ -1,44 +0,0 @@ -local config = require("oil.config") -local M = {} - -M.recursive_delete = function(path, cb) - local stdout = {} - local stderr = {} - local cmd - if config.trash_command:find("%s") then - cmd = string.format("%s %s", config.trash_command, vim.fn.shellescape(path)) - else - cmd = { config.trash_command, path } - end - 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 = function(j, exit_code) - if exit_code == 0 then - cb() - else - cb( - string.format( - "Error moving '%s' to trash:\n stdout: %s\n stderr: %s", - path, - table.concat(stdout, "\n "), - table.concat(stderr, "\n ") - ) - ) - end - end, - }) - if jid == 0 then - cb(string.format("Passed invalid argument '%s' to '%s'", path, config.trash_command)) - elseif jid == -1 then - cb(string.format("'%s' is not executable", config.trash_command)) - end -end - -return M diff --git a/lua/oil/adapters/s3.lua b/lua/oil/adapters/s3.lua new file mode 100644 index 0000000..b7b5400 --- /dev/null +++ b/lua/oil/adapters/s3.lua @@ -0,0 +1,389 @@ +local config = require("oil.config") +local constants = require("oil.constants") +local files = require("oil.adapters.files") +local fs = require("oil.fs") +local loading = require("oil.loading") +local pathutil = require("oil.pathutil") +local s3fs = require("oil.adapters.s3.s3fs") +local util = require("oil.util") +local M = {} + +local FIELD_META = constants.FIELD_META + +---@class (exact) oil.s3Url +---@field scheme string +---@field bucket nil|string +---@field path nil|string + +---@param oil_url string +---@return oil.s3Url +M.parse_url = function(oil_url) + local scheme, url = util.parse_url(oil_url) + assert(scheme and url, string.format("Malformed input url '%s'", oil_url)) + local ret = { scheme = scheme } + local bucket, path = url:match("^([^/]+)/?(.*)$") + ret.bucket = bucket + ret.path = path ~= "" and path or nil + if not ret.bucket and ret.path then + error(string.format("Parsing error for s3 url: %s", oil_url)) + end + ---@cast ret oil.s3Url + return ret +end + +---@param url oil.s3Url +---@return string +local function url_to_str(url) + local pieces = { url.scheme } + if url.bucket then + assert(url.bucket ~= "") + table.insert(pieces, url.bucket) + table.insert(pieces, "/") + end + if url.path then + assert(url.path ~= "") + table.insert(pieces, url.path) + end + return table.concat(pieces, "") +end + +---@param url oil.s3Url +---@param is_folder boolean +---@return string +local function url_to_s3(url, is_folder) + local pieces = { "s3://" } + if url.bucket then + assert(url.bucket ~= "") + table.insert(pieces, url.bucket) + table.insert(pieces, "/") + end + if url.path then + assert(url.path ~= "") + table.insert(pieces, url.path) + if is_folder and not vim.endswith(url.path, "/") then + table.insert(pieces, "/") + end + end + return table.concat(pieces, "") +end + +---@param url oil.s3Url +---@return boolean +local function is_bucket(url) + assert(url.bucket and url.bucket ~= "") + if url.path then + assert(url.path ~= "") + return false + end + return true +end + +local s3_columns = {} +s3_columns.size = { + render = function(entry, conf) + local meta = entry[FIELD_META] + if not meta or not meta.size then + return "" + elseif 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, + + get_sort_value = function(entry) + local meta = entry[FIELD_META] + if meta and meta.size then + return meta.size + else + return 0 + end + end, +} + +s3_columns.birthtime = { + render = function(entry, conf) + local meta = entry[FIELD_META] + if not meta or not meta.date then + return "" + else + return meta.date + end + end, + + parse = function(line, conf) + return line:match("^(%d+%-%d+%-%d+%s%d+:%d+:%d+)%s+(.*)$") + end, + + get_sort_value = function(entry) + local meta = entry[FIELD_META] + if meta and meta.date then + local year, month, day, hour, min, sec = + meta.date:match("^(%d+)%-(%d+)%-(%d+)%s(%d+):(%d+):(%d+)$") + local time = + os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec }) + return time + else + return 0 + end + end, +} + +---@param name string +---@return nil|oil.ColumnDefinition +M.get_column = function(name) + return s3_columns[name] +end + +---@param bufname string +---@return string +M.get_parent = function(bufname) + local res = M.parse_url(bufname) + if res.path then + assert(res.path ~= "") + local path = pathutil.parent(res.path) + res.path = path ~= "" and path or nil + else + res.bucket = nil + end + return url_to_str(res) +end + +---@param url string +---@param callback fun(url: string) +M.normalize_url = function(url, callback) + local res = M.parse_url(url) + callback(url_to_str(res)) +end + +---@param url string +---@param column_defs string[] +---@param callback fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun()) +M.list = function(url, column_defs, callback) + if vim.fn.executable("aws") ~= 1 then + callback("`aws` is not executable. Can you run `aws s3 ls`?") + return + end + + local res = M.parse_url(url) + s3fs.list_dir(url, url_to_s3(res, true), callback) +end + +---@param bufnr integer +---@return boolean +M.is_modifiable = function(bufnr) + -- default assumption is that everything is modifiable + return true +end + +---@param action oil.Action +---@return string +M.render_action = function(action) + local is_folder = action.entry_type == "directory" + if action.type == "create" then + local res = M.parse_url(action.url) + local extra = is_bucket(res) and "BUCKET " or "" + return string.format("CREATE %s%s", extra, url_to_s3(res, is_folder)) + elseif action.type == "delete" then + local res = M.parse_url(action.url) + local extra = is_bucket(res) and "BUCKET " or "" + return string.format("DELETE %s%s", extra, url_to_s3(res, is_folder)) + 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(src) + assert(path) + src = files.to_short_os_path(path, action.entry_type) + dest = url_to_s3(M.parse_url(dest), is_folder) + elseif config.get_adapter_by_scheme(dest) ~= M then + local _, path = util.parse_url(dest) + assert(path) + dest = files.to_short_os_path(path, action.entry_type) + src = url_to_s3(M.parse_url(src), is_folder) + 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) + local is_folder = action.entry_type == "directory" + if action.type == "create" then + local res = M.parse_url(action.url) + local bucket = is_bucket(res) + + if action.entry_type == "directory" and bucket then + s3fs.mb(url_to_s3(res, true), cb) + elseif action.entry_type == "directory" or action.entry_type == "file" then + s3fs.touch(url_to_s3(res, is_folder), cb) + else + cb(string.format("Bad entry type on s3 create action: %s", action.entry_type)) + end + elseif action.type == "delete" then + local res = M.parse_url(action.url) + local bucket = is_bucket(res) + + if action.entry_type == "directory" and bucket then + s3fs.rb(url_to_s3(res, true), cb) + elseif action.entry_type == "directory" or action.entry_type == "file" then + s3fs.rm(url_to_s3(res, is_folder), is_folder, cb) + else + cb(string.format("Bad entry type on s3 delete action: %s", action.entry_type)) + end + elseif action.type == "move" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if + (src_adapter ~= M and src_adapter ~= files) or (dest_adapter ~= M and dest_adapter ~= files) + then + cb( + string.format( + "We should never attempt to move from the %s adapter to the %s adapter.", + src_adapter.name, + dest_adapter.name + ) + ) + end + + local src, _ + if src_adapter == M then + local src_res = M.parse_url(action.src_url) + src = url_to_s3(src_res, is_folder) + else + _, src = util.parse_url(action.src_url) + end + assert(src) + + local dest + if dest_adapter == M then + local dest_res = M.parse_url(action.dest_url) + dest = url_to_s3(dest_res, is_folder) + else + _, dest = util.parse_url(action.dest_url) + end + assert(dest) + + s3fs.mv(src, dest, is_folder, cb) + elseif action.type == "copy" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if + (src_adapter ~= M and src_adapter ~= files) or (dest_adapter ~= M and dest_adapter ~= files) + then + cb( + string.format( + "We should never attempt to copy from the %s adapter to the %s adapter.", + src_adapter.name, + dest_adapter.name + ) + ) + end + + local src, _ + if src_adapter == M then + local src_res = M.parse_url(action.src_url) + src = url_to_s3(src_res, is_folder) + else + _, src = util.parse_url(action.src_url) + end + assert(src) + + local dest + if dest_adapter == M then + local dest_res = M.parse_url(action.dest_url) + dest = url_to_s3(dest_res, is_folder) + else + _, dest = util.parse_url(action.dest_url) + end + assert(dest) + + s3fs.cp(src, dest, is_folder, cb) + else + cb(string.format("Bad action type: %s", action.type)) + end +end + +M.supported_cross_adapter_actions = { files = "move" } + +---@param bufnr integer +M.read_file = function(bufnr) + loading.set_loading(bufnr, true) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local url = M.parse_url(bufname) + local basename = pathutil.basename(bufname) + local cache_dir = vim.fn.stdpath("cache") + assert(type(cache_dir) == "string") + local tmpdir = fs.join(cache_dir, "oil") + fs.mkdirp(tmpdir) + local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, "s3_XXXXXX")) + if fd then + vim.loop.fs_close(fd) + end + local tmp_bufnr = vim.fn.bufadd(tmpfile) + + s3fs.cp(url_to_s3(url, false), tmpfile, false, function(err) + loading.set_loading(bufnr, false) + vim.bo[bufnr].modifiable = true + vim.cmd.doautocmd({ args = { "BufReadPre", bufname }, mods = { silent = true } }) + if err then + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, vim.split(err, "\n")) + else + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, {}) + vim.api.nvim_buf_call(bufnr, function() + vim.cmd.read({ args = { tmpfile }, mods = { silent = true } }) + end) + vim.loop.fs_unlink(tmpfile) + vim.api.nvim_buf_set_lines(bufnr, 0, 1, true, {}) + end + vim.bo[bufnr].modified = false + local filetype = vim.filetype.match({ buf = bufnr, filename = basename }) + if filetype then + vim.bo[bufnr].filetype = filetype + end + vim.cmd.doautocmd({ args = { "BufReadPost", bufname }, mods = { silent = true } }) + vim.api.nvim_buf_delete(tmp_bufnr, { force = true }) + end) +end + +---@param bufnr integer +M.write_file = function(bufnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local url = M.parse_url(bufname) + local cache_dir = vim.fn.stdpath("cache") + assert(type(cache_dir) == "string") + local tmpdir = fs.join(cache_dir, "oil") + local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, "s3_XXXXXXXX")) + if fd then + vim.loop.fs_close(fd) + end + vim.cmd.doautocmd({ args = { "BufWritePre", bufname }, mods = { silent = true } }) + vim.bo[bufnr].modifiable = false + vim.cmd.write({ args = { tmpfile }, bang = true, mods = { silent = true, noautocmd = true } }) + local tmp_bufnr = vim.fn.bufadd(tmpfile) + + s3fs.cp(tmpfile, url_to_s3(url, false), false, function(err) + vim.bo[bufnr].modifiable = true + if err then + vim.notify(string.format("Error writing file: %s", err), vim.log.levels.ERROR) + else + vim.bo[bufnr].modified = false + vim.cmd.doautocmd({ args = { "BufWritePost", bufname }, mods = { silent = true } }) + end + vim.loop.fs_unlink(tmpfile) + vim.api.nvim_buf_delete(tmp_bufnr, { force = true }) + end) +end + +return M diff --git a/lua/oil/adapters/s3/s3fs.lua b/lua/oil/adapters/s3/s3fs.lua new file mode 100644 index 0000000..2e16307 --- /dev/null +++ b/lua/oil/adapters/s3/s3fs.lua @@ -0,0 +1,149 @@ +local cache = require("oil.cache") +local config = require("oil.config") +local constants = require("oil.constants") +local shell = require("oil.shell") +local util = require("oil.util") + +local M = {} + +local FIELD_META = constants.FIELD_META + +---@param line string +---@return string Name of entry +---@return oil.EntryType +---@return table Metadata for entry +local function parse_ls_line_bucket(line) + local date, name = line:match("^(%d+%-%d+%-%d+%s%d+:%d+:%d+)%s+(.*)$") + if not date or not name then + error(string.format("Could not parse '%s'", line)) + end + local type = "directory" + local meta = { date = date } + return name, type, meta +end + +---@param line string +---@return string Name of entry +---@return oil.EntryType +---@return table Metadata for entry +local function parse_ls_line_file(line) + local name = line:match("^%s+PRE%s+(.*)/$") + local type = "directory" + local meta = {} + if name then + return name, type, meta + end + local date, size + date, size, name = line:match("^(%d+%-%d+%-%d+%s%d+:%d+:%d+)%s+(%d+)%s+(.*)$") + if not name then + error(string.format("Could not parse '%s'", line)) + end + type = "file" + meta = { date = date, size = tonumber(size) } + return name, type, meta +end + +---@param cmd string[] cmd and flags +---@return string[] Shell command to run +local function create_s3_command(cmd) + local full_cmd = vim.list_extend({ "aws", "s3" }, cmd) + return vim.list_extend(full_cmd, config.extra_s3_args) +end + +---@param url string +---@param path string +---@param callback fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun()) +function M.list_dir(url, path, callback) + local cmd = create_s3_command({ "ls", path, "--color=off", "--no-cli-pager" }) + shell.run(cmd, function(err, lines) + if err then + return callback(err) + end + assert(lines) + local cache_entries = {} + local url_path, _ + _, url_path = util.parse_url(url) + local is_top_level = url_path == nil or url_path:match("/") == nil + local parse_ls_line = is_top_level and parse_ls_line_bucket or parse_ls_line_file + for _, line in ipairs(lines) do + if line ~= "" then + local name, type, meta = parse_ls_line(line) + -- in s3 '-' can be used to create an "empty folder" + if name ~= "-" then + local cache_entry = cache.create_entry(url, name, type) + table.insert(cache_entries, cache_entry) + cache_entry[FIELD_META] = meta + end + end + end + callback(nil, cache_entries) + end) +end + +--- Create files +---@param path string +---@param callback fun(err: nil|string) +function M.touch(path, callback) + -- here "-" means that we copy from stdin + local cmd = create_s3_command({ "cp", "-", path }) + shell.run(cmd, { stdin = "null" }, callback) +end + +--- Remove files +---@param path string +---@param is_folder boolean +---@param callback fun(err: nil|string) +function M.rm(path, is_folder, callback) + local main_cmd = { "rm", path } + if is_folder then + table.insert(main_cmd, "--recursive") + end + local cmd = create_s3_command(main_cmd) + shell.run(cmd, callback) +end + +--- Remove bucket +---@param bucket string +---@param callback fun(err: nil|string) +function M.rb(bucket, callback) + local cmd = create_s3_command({ "rb", bucket }) + shell.run(cmd, callback) +end + +--- Make bucket +---@param bucket string +---@param callback fun(err: nil|string) +function M.mb(bucket, callback) + local cmd = create_s3_command({ "mb", bucket }) + shell.run(cmd, callback) +end + +--- Move files +---@param src string +---@param dest string +---@param is_folder boolean +---@param callback fun(err: nil|string) +function M.mv(src, dest, is_folder, callback) + local main_cmd = { "mv", src, dest } + if is_folder then + table.insert(main_cmd, "--recursive") + end + local cmd = create_s3_command(main_cmd) + shell.run(cmd, callback) +end + +--- Copy files +---@param src string +---@param dest string +---@param is_folder boolean +---@param callback fun(err: nil|string) +function M.cp(src, dest, is_folder, callback) + local main_cmd = { "cp", src, dest } + if is_folder then + table.insert(main_cmd, "--recursive") + end + local cmd = create_s3_command(main_cmd) + shell.run(cmd, callback) +end + +return M diff --git a/lua/oil/adapters/ssh.lua b/lua/oil/adapters/ssh.lua index 02637a8..ae4291f 100644 --- a/lua/oil/adapters/ssh.lua +++ b/lua/oil/adapters/ssh.lua @@ -303,8 +303,8 @@ M.perform_action = function(action, cb) local conn = get_connection(action.url) conn:rm(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) + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) if src_adapter == M and dest_adapter == M then local src_res = M.parse_url(action.src_url) local dest_res = M.parse_url(action.dest_url) @@ -324,8 +324,8 @@ M.perform_action = function(action, cb) 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) + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) if src_adapter == M and dest_adapter == M then local src_res = M.parse_url(action.src_url) local dest_res = M.parse_url(action.dest_url) diff --git a/lua/oil/adapters/ssh/sshfs.lua b/lua/oil/adapters/ssh/sshfs.lua index 341834c..0dcc169 100644 --- a/lua/oil/adapters/ssh/sshfs.lua +++ b/lua/oil/adapters/ssh/sshfs.lua @@ -42,10 +42,17 @@ local function parse_ls_line(line) local name, size, date, major, minor if typechar == "c" or typechar == "b" then major, minor, date, name = rem:match("^(%d+)%s*,%s*(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)") + if name == nil then + major, minor, date, name = + rem:match("^(%d+)%s*,%s*(%d+)%s+(%d+%-%d+%-%d+%s+%d%d:?%d%d)%s+(.*)") + end meta.major = tonumber(major) meta.minor = tonumber(minor) else size, date, name = rem:match("^(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)") + if name == nil then + size, date, name = rem:match("^(%d+)%s+(%d+%-%d+%-%d+%s+%d%d:?%d%d)%s+(.*)") + end meta.size = tonumber(size) end meta.iso_modified_date = date diff --git a/lua/oil/adapters/trash/freedesktop.lua b/lua/oil/adapters/trash/freedesktop.lua index c669730..10c0749 100644 --- a/lua/oil/adapters/trash/freedesktop.lua +++ b/lua/oil/adapters/trash/freedesktop.lua @@ -447,7 +447,7 @@ M.render_action = function(action) local entry = assert(cache.get_entry_by_url(action.url)) local meta = entry[FIELD_META] ---@type oil.TrashInfo - local trash_info = meta and meta.trash_info + local trash_info = assert(meta).trash_info local short_path = fs.shorten_path(trash_info.original_path) return string.format(" PURGE %s", short_path) elseif action.type == "move" then @@ -561,7 +561,7 @@ M.perform_action = function(action, cb) local entry = assert(cache.get_entry_by_url(action.url)) local meta = entry[FIELD_META] ---@type oil.TrashInfo - local trash_info = meta and meta.trash_info + local trash_info = assert(meta).trash_info purge(trash_info, cb) elseif action.type == "move" then local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) @@ -576,7 +576,7 @@ M.perform_action = function(action, cb) local entry = assert(cache.get_entry_by_url(action.src_url)) local meta = entry[FIELD_META] ---@type oil.TrashInfo - local trash_info = meta and meta.trash_info + local trash_info = assert(meta).trash_info fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err) if err then return cb(err) @@ -607,7 +607,7 @@ M.perform_action = function(action, cb) local entry = assert(cache.get_entry_by_url(action.src_url)) local meta = entry[FIELD_META] ---@type oil.TrashInfo - local trash_info = meta and meta.trash_info + local trash_info = assert(meta).trash_info fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb) else error("Must be moving files into or out of trash") diff --git a/lua/oil/adapters/trash/windows.lua b/lua/oil/adapters/trash/windows.lua index 4605567..f7634e1 100644 --- a/lua/oil/adapters/trash/windows.lua +++ b/lua/oil/adapters/trash/windows.lua @@ -37,10 +37,10 @@ local win_addslash = function(path) end ---@class oil.WindowsTrashInfo ----@field trash_file string? ----@field original_path string? ----@field deletion_date string? ----@field info_file string? +---@field trash_file string +---@field original_path string +---@field deletion_date integer +---@field info_file? string ---@param url string ---@param column_defs string[] @@ -96,6 +96,7 @@ M.list = function(url, column_defs, cb) end cache_entry[FIELD_META] = { stat = nil, + ---@type oil.WindowsTrashInfo trash_info = { trash_file = entry.Path, original_path = entry.OriginalPath, @@ -265,7 +266,7 @@ M.render_action = function(action) local entry = assert(cache.get_entry_by_url(action.url)) local meta = entry[FIELD_META] ---@type oil.WindowsTrashInfo - local trash_info = meta and meta.trash_info + local trash_info = assert(meta).trash_info local short_path = fs.shorten_path(trash_info.original_path) return string.format(" PURGE %s", short_path) elseif action.type == "move" then diff --git a/lua/oil/adapters/trash/windows/powershell-connection.lua b/lua/oil/adapters/trash/windows/powershell-connection.lua index a296a7e..332defb 100644 --- a/lua/oil/adapters/trash/windows/powershell-connection.lua +++ b/lua/oil/adapters/trash/windows/powershell-connection.lua @@ -28,6 +28,16 @@ end ---@param init_command? string function PowershellConnection:_init(init_command) + -- For some reason beyond my understanding, at least one of the following + -- things requires `noshellslash` to avoid the embeded powershell process to + -- send only "" to the stdout (never calling the callback because + -- "===DONE(True)===" is never sent to stdout) + -- * vim.fn.jobstart + -- * cmd.exe + -- * powershell.exe + local saved_shellslash = vim.o.shellslash + vim.o.shellslash = false + -- 65001 is the UTF-8 codepage -- powershell needs to be launched with the UTF-8 codepage to use it for both stdin and stdout local jid = vim.fn.jobstart({ @@ -57,6 +67,7 @@ function PowershellConnection:_init(init_command) end end, }) + vim.o.shellslash = saved_shellslash if jid == 0 then self:_set_error("passed invalid arguments to 'powershell'") diff --git a/lua/oil/clipboard.lua b/lua/oil/clipboard.lua new file mode 100644 index 0000000..04498f5 --- /dev/null +++ b/lua/oil/clipboard.lua @@ -0,0 +1,370 @@ +local cache = require("oil.cache") +local columns = require("oil.columns") +local config = require("oil.config") +local fs = require("oil.fs") +local oil = require("oil") +local parser = require("oil.mutator.parser") +local util = require("oil.util") +local view = require("oil.view") + +local M = {} + +---@return "wayland"|"x11"|nil +local function get_linux_session_type() + local xdg_session_type = vim.env.XDG_SESSION_TYPE + if not xdg_session_type then + return + end + xdg_session_type = xdg_session_type:lower() + if xdg_session_type:find("x11") then + return "x11" + elseif xdg_session_type:find("wayland") then + return "wayland" + else + return nil + end +end + +---@return boolean +local function is_linux_desktop_gnome() + local cur_desktop = vim.env.XDG_CURRENT_DESKTOP + local session_desktop = vim.env.XDG_SESSION_DESKTOP + local idx = session_desktop and session_desktop:lower():find("gnome") + or cur_desktop and cur_desktop:lower():find("gnome") + return idx ~= nil or cur_desktop == "X-Cinnamon" or cur_desktop == "XFCE" +end + +---@param winid integer +---@param entry oil.InternalEntry +---@param column_defs oil.ColumnSpec[] +---@param adapter oil.Adapter +---@param bufnr integer +local function write_pasted(winid, entry, column_defs, adapter, bufnr) + local col_width = {} + for i in ipairs(column_defs) do + col_width[i + 1] = 1 + end + local line_table = + { view.format_entry_cols(entry, column_defs, col_width, adapter, false, bufnr) } + local lines, _ = util.render_table(line_table, col_width) + local pos = vim.api.nvim_win_get_cursor(winid) + vim.api.nvim_buf_set_lines(bufnr, pos[1], pos[1], true, lines) +end + +---@param parent_url string +---@param entry oil.InternalEntry +local function remove_entry_from_parent_buffer(parent_url, entry) + local bufnr = vim.fn.bufadd(parent_url) + assert(vim.api.nvim_buf_is_loaded(bufnr), "Expected parent buffer to be loaded during paste") + local adapter = assert(util.get_adapter(bufnr)) + local column_defs = columns.get_supported_columns(adapter) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + local result = parser.parse_line(adapter, line, column_defs) + if result and result.entry == entry then + vim.api.nvim_buf_set_lines(bufnr, i - 1, i, false, {}) + return + end + end + local exported = util.export_entry(entry) + vim.notify( + string.format("Error: could not delete original file '%s'", exported.name), + vim.log.levels.ERROR + ) +end + +---@param paths string[] +---@param delete_original? boolean +local function paste_paths(paths, delete_original) + local bufnr = vim.api.nvim_get_current_buf() + local scheme = "oil://" + local adapter = assert(config.get_adapter_by_scheme(scheme)) + local column_defs = columns.get_supported_columns(scheme) + local winid = vim.api.nvim_get_current_win() + + local parent_urls = {} + local pending_paths = {} + + -- Handle as many paths synchronously as possible + for _, path in ipairs(paths) do + -- Trim the trailing slash off directories + if vim.endswith(path, "/") then + path = path:sub(1, -2) + end + + local ori_entry = cache.get_entry_by_url(scheme .. path) + local parent_url = util.addslash(scheme .. vim.fs.dirname(path)) + if ori_entry then + write_pasted(winid, ori_entry, column_defs, adapter, bufnr) + if delete_original then + remove_entry_from_parent_buffer(parent_url, ori_entry) + end + else + parent_urls[parent_url] = true + table.insert(pending_paths, path) + end + end + + -- If all paths could be handled synchronously, we're done + if #pending_paths == 0 then + return + end + + -- Process the remaining paths by asynchronously loading them + local cursor = vim.api.nvim_win_get_cursor(winid) + local complete_loading = util.cb_collect(#vim.tbl_keys(parent_urls), function(err) + if err then + vim.notify(string.format("Error loading parent directory: %s", err), vim.log.levels.ERROR) + else + -- Something in this process moves the cursor to the top of the window, so have to restore it + vim.api.nvim_win_set_cursor(winid, cursor) + + for _, path in ipairs(pending_paths) do + local ori_entry = cache.get_entry_by_url(scheme .. path) + if ori_entry then + write_pasted(winid, ori_entry, column_defs, adapter, bufnr) + if delete_original then + local parent_url = util.addslash(scheme .. vim.fs.dirname(path)) + remove_entry_from_parent_buffer(parent_url, ori_entry) + end + else + vim.notify( + string.format("The pasted file '%s' could not be found", path), + vim.log.levels.ERROR + ) + end + end + end + end) + + for parent_url, _ in pairs(parent_urls) do + local new_bufnr = vim.api.nvim_create_buf(false, false) + vim.api.nvim_buf_set_name(new_bufnr, parent_url) + oil.load_oil_buffer(new_bufnr) + util.run_after_load(new_bufnr, complete_loading) + end +end + +---@return integer start +---@return integer end +local function range_from_selection() + -- [bufnum, lnum, col, off]; both row and column 1-indexed + local start = vim.fn.getpos("v") + local end_ = vim.fn.getpos(".") + local start_row = start[2] + local end_row = end_[2] + + if start_row > end_row then + start_row, end_row = end_row, start_row + end + + return start_row, end_row +end + +M.copy_to_system_clipboard = function() + local dir = oil.get_current_dir() + if not dir then + vim.notify("System clipboard only works for local files", vim.log.levels.ERROR) + return + end + + local entries = {} + local mode = vim.api.nvim_get_mode().mode + if mode == "v" or mode == "V" then + if fs.is_mac then + vim.notify( + "Copying multiple paths to clipboard is not supported on mac", + vim.log.levels.ERROR + ) + return + end + local start_row, end_row = range_from_selection() + for i = start_row, end_row do + table.insert(entries, oil.get_entry_on_line(0, i)) + end + + -- leave visual mode + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + else + table.insert(entries, oil.get_cursor_entry()) + end + + -- This removes holes in the list-like table + entries = vim.tbl_values(entries) + + if #entries == 0 then + vim.notify("Could not find local file under cursor", vim.log.levels.WARN) + return + end + local paths = {} + for _, entry in ipairs(entries) do + table.insert(paths, dir .. entry.name) + end + local cmd = {} + local stdin + if fs.is_mac then + cmd = { + "osascript", + "-e", + "on run args", + "-e", + "set the clipboard to POSIX file (first item of args)", + "-e", + "end run", + paths[1], + } + elseif fs.is_linux then + local xdg_session_type = get_linux_session_type() + if xdg_session_type == "x11" then + vim.list_extend(cmd, { "xclip", "-i", "-selection", "clipboard" }) + elseif xdg_session_type == "wayland" then + table.insert(cmd, "wl-copy") + else + vim.notify("System clipboard not supported, check $XDG_SESSION_TYPE", vim.log.levels.ERROR) + return + end + local urls = {} + for _, path in ipairs(paths) do + table.insert(urls, "file://" .. path) + end + if is_linux_desktop_gnome() then + stdin = string.format("copy\n%s\0", table.concat(urls, "\n")) + vim.list_extend(cmd, { "-t", "x-special/gnome-copied-files" }) + else + stdin = table.concat(urls, "\n") .. "\n" + vim.list_extend(cmd, { "-t", "text/uri-list" }) + end + else + vim.notify("System clipboard not supported on Windows", vim.log.levels.ERROR) + return + end + + if vim.fn.executable(cmd[1]) == 0 then + vim.notify(string.format("Could not find executable '%s'", cmd[1]), vim.log.levels.ERROR) + return + end + local stderr = "" + local jid = vim.fn.jobstart(cmd, { + stderr_buffered = true, + on_stderr = function(_, data) + stderr = table.concat(data, "\n") + end, + on_exit = function(j, exit_code) + if exit_code ~= 0 then + vim.notify( + string.format("Error copying '%s' to system clipboard\n%s", vim.inspect(paths), stderr), + vim.log.levels.ERROR + ) + else + if #paths == 1 then + vim.notify(string.format("Copied '%s' to system clipboard", paths[1])) + else + vim.notify(string.format("Copied %d files to system clipboard", #paths)) + end + end + end, + }) + assert(jid > 0, "Failed to start job") + if stdin then + vim.api.nvim_chan_send(jid, stdin) + vim.fn.chanclose(jid, "stdin") + end +end + +---@param lines string[] +---@return string[] +local function handle_paste_output_mac(lines) + local ret = {} + for _, line in ipairs(lines) do + if not line:match("^%s*$") then + table.insert(ret, line) + end + end + return ret +end + +---@param lines string[] +---@return string[] +local function handle_paste_output_linux(lines) + local ret = {} + for _, line in ipairs(lines) do + local path = line:match("^file://(.+)$") + if path then + table.insert(ret, util.url_unescape(path)) + end + end + return ret +end + +---@param delete_original? boolean Delete the source file after pasting +M.paste_from_system_clipboard = function(delete_original) + local dir = oil.get_current_dir() + if not dir then + return + end + local cmd = {} + local handle_paste_output + if fs.is_mac then + cmd = { + "osascript", + "-e", + "on run", + "-e", + "POSIX path of (the clipboard as «class furl»)", + "-e", + "end run", + } + handle_paste_output = handle_paste_output_mac + elseif fs.is_linux then + local xdg_session_type = get_linux_session_type() + if xdg_session_type == "x11" then + vim.list_extend(cmd, { "xclip", "-o", "-selection", "clipboard" }) + elseif xdg_session_type == "wayland" then + table.insert(cmd, "wl-paste") + else + vim.notify("System clipboard not supported, check $XDG_SESSION_TYPE", vim.log.levels.ERROR) + return + end + if is_linux_desktop_gnome() then + vim.list_extend(cmd, { "-t", "x-special/gnome-copied-files" }) + else + vim.list_extend(cmd, { "-t", "text/uri-list" }) + end + handle_paste_output = handle_paste_output_linux + else + vim.notify("System clipboard not supported on Windows", vim.log.levels.ERROR) + return + end + local paths + local stderr = "" + if vim.fn.executable(cmd[1]) == 0 then + vim.notify(string.format("Could not find executable '%s'", cmd[1]), vim.log.levels.ERROR) + return + end + local jid = vim.fn.jobstart(cmd, { + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(j, data) + local lines = vim.split(table.concat(data, "\n"), "\r?\n") + paths = handle_paste_output(lines) + end, + on_stderr = function(_, data) + stderr = table.concat(data, "\n") + end, + on_exit = function(j, exit_code) + if exit_code ~= 0 or not paths then + vim.notify( + string.format("Error pasting from system clipboard: %s", stderr), + vim.log.levels.ERROR + ) + elseif #paths == 0 then + vim.notify("No valid files found in system clipboard", vim.log.levels.WARN) + else + paste_paths(paths, delete_original) + end + end, + }) + assert(jid > 0, "Failed to start job") +end + +return M diff --git a/lua/oil/columns.lua b/lua/oil/columns.lua index cc3b445..975576f 100644 --- a/lua/oil/columns.lua +++ b/lua/oil/columns.lua @@ -53,7 +53,7 @@ M.get_supported_columns = function(adapter_or_scheme) return ret end -local EMPTY = { "-", "Comment" } +local EMPTY = { "-", "OilEmpty" } M.EMPTY = EMPTY @@ -98,13 +98,13 @@ end M.parse_col = function(adapter, line, col_def) local name, conf = util.split_config(col_def) -- If rendering failed, there will just be a "-" - local empty_col, rem = line:match("^(-%s+)(.*)$") + local empty_col, rem = line:match("^%s*(-%s+)(.*)$") if empty_col then return nil, rem end local column = M.get_column(adapter, name) if column then - return column.parse(line, conf) + return column.parse(line:gsub("^%s+", ""), conf) end end @@ -200,7 +200,7 @@ local function is_entry_directory(entry) return true elseif type == "link" then local meta = entry[FIELD_META] - return meta and meta.link_stat and meta.link_stat.type == "directory" + return (meta and meta.link_stat and meta.link_stat.type == "directory") == true else return false end @@ -228,8 +228,8 @@ M.register("type", { end, }) -local function pad_number(int) - return string.format("%012d", int) +local function adjust_number(int) + return string.format("%03d%s", #int, int) end M.register("name", { @@ -256,14 +256,16 @@ M.register("name", { end end else - if config.view_options.case_insensitive then - return function(entry) - return entry[FIELD_NAME]:gsub("%d+", pad_number):lower() - end - else - return function(entry) - return entry[FIELD_NAME]:gsub("%d+", pad_number) + local memo = {} + return function(entry) + if memo[entry] == nil then + local name = entry[FIELD_NAME]:gsub("0*(%d+)", adjust_number) + if config.view_options.case_insensitive then + name = name:lower() + end + memo[entry] = name end + return memo[entry] end end end, diff --git a/lua/oil/config.lua b/lua/oil/config.lua index 562562c..cafa783 100644 --- a/lua/oil/config.lua +++ b/lua/oil/config.lua @@ -69,7 +69,7 @@ local default_config = { ["-"] = { "actions.parent", mode = "n" }, ["_"] = { "actions.open_cwd", mode = "n" }, ["`"] = { "actions.cd", mode = "n" }, - ["~"] = { "actions.cd", opts = { scope = "tab" }, mode = "n" }, + ["g~"] = { "actions.cd", opts = { scope = "tab" }, mode = "n" }, ["gs"] = { "actions.change_sort", mode = "n" }, ["gx"] = "actions.open_external", ["g."] = { "actions.toggle_hidden", mode = "n" }, @@ -107,6 +107,8 @@ local default_config = { }, -- Extra arguments to pass to SCP when moving/copying files over SSH extra_scp_args = {}, + -- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3 + extra_s3_args = {}, -- EXPERIMENTAL support for performing file operations with git git = { -- Return true to automatically git add/mv/rm files @@ -127,7 +129,7 @@ local default_config = { -- max_width and max_height can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) max_width = 0, max_height = 0, - border = "rounded", + border = nil, win_options = { winblend = 0, }, @@ -172,7 +174,7 @@ local default_config = { min_height = { 5, 0.1 }, -- optionally define an integer/float for the exact height of the preview window height = nil, - border = "rounded", + border = nil, win_options = { winblend = 0, }, @@ -185,7 +187,7 @@ local default_config = { max_height = { 10, 0.9 }, min_height = { 5, 0.1 }, height = nil, - border = "rounded", + border = nil, minimized_border = "none", win_options = { winblend = 0, @@ -193,20 +195,25 @@ local default_config = { }, -- Configuration for the floating SSH window ssh = { - border = "rounded", + border = nil, }, -- Configuration for the floating keymaps help window keymaps_help = { - border = "rounded", + border = nil, }, } -- The adapter API hasn't really stabilized yet. We're not ready to advertise or encourage people to -- write their own adapters, and so there's no real reason to edit these config options. For that -- reason, I'm taking them out of the section above so they won't show up in the autogen docs. + +-- not "oil-s3://" on older neovim versions, since it doesn't open buffers correctly with a number +-- in the name +local oil_s3_string = vim.fn.has("nvim-0.12") == 1 and "oil-s3://" or "oil-sss://" default_config.adapters = { ["oil://"] = "files", ["oil-ssh://"] = "ssh", + [oil_s3_string] = "s3", ["oil-trash://"] = "trash", } default_config.adapter_aliases = {} @@ -217,7 +224,6 @@ default_config.view_options.highlight_filename = nil ---@class oil.Config ---@field adapters table Hidden from SetupOpts ---@field adapter_aliases table Hidden from SetupOpts ----@field trash_command? string Deprecated option that we should clean up soon ---@field silence_scp_warning? boolean Undocumented option ---@field default_file_explorer boolean ---@field columns oil.ColumnSpec[] @@ -234,6 +240,7 @@ default_config.view_options.highlight_filename = nil ---@field use_default_keymaps boolean ---@field view_options oil.ViewOptions ---@field extra_scp_args string[] +---@field extra_s3_args string[] ---@field git oil.GitOptions ---@field float oil.FloatWindowConfig ---@field preview_win oil.PreviewWindowConfig @@ -262,6 +269,7 @@ local M = {} ---@field use_default_keymaps? boolean Set to false to disable all of the above keymaps ---@field view_options? oil.SetupViewOptions Configure which files are shown and how they are shown. ---@field extra_scp_args? string[] Extra arguments to pass to SCP when moving/copying files over SSH +---@field extra_s3_args? string[] Extra arguments to pass to aws s3 when moving/copying files using aws s3 ---@field git? oil.SetupGitOptions EXPERIMENTAL support for performing file operations with git ---@field float? oil.SetupFloatWindowConfig Configuration for the floating window in oil.open_float ---@field preview_win? oil.SetupPreviewWindowConfig Configuration for the file preview window @@ -394,13 +402,6 @@ local M = {} M.setup = function(opts) opts = opts or {} - if opts.trash_command then - vim.notify( - "[oil.nvim] trash_command is deprecated. Use built-in trash functionality instead (:help oil-trash).\nCompatibility will be removed on 2025-06-01.", - vim.log.levels.WARN - ) - end - local new_conf = vim.tbl_deep_extend("keep", opts, default_config) if not new_conf.use_default_keymaps then new_conf.keymaps = opts.keymaps or {} @@ -412,6 +413,17 @@ M.setup = function(opts) end end + -- Backwards compatibility for old versions that don't support winborder + if vim.fn.has("nvim-0.11") == 0 then + new_conf = vim.tbl_deep_extend("keep", new_conf, { + float = { border = "rounded" }, + confirmation = { border = "rounded" }, + progress = { border = "rounded" }, + ssh = { border = "rounded" }, + keymaps_help = { border = "rounded" }, + }) + end + -- Backwards compatibility. We renamed the 'preview' window config to be called 'confirmation'. if opts.preview and not opts.confirmation then new_conf.confirmation = vim.tbl_deep_extend("keep", opts.preview, default_config.confirmation) @@ -464,10 +476,6 @@ M.get_adapter_by_scheme = function(scheme) if adapter == nil then local name = M.adapters[scheme] if not name then - vim.notify( - string.format("Could not find oil adapter for scheme '%s'", scheme), - vim.log.levels.ERROR - ) return nil end local ok @@ -478,7 +486,6 @@ M.get_adapter_by_scheme = function(scheme) 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 diff --git a/lua/oil/constants.lua b/lua/oil/constants.lua index e1ef56b..3f5a38a 100644 --- a/lua/oil/constants.lua +++ b/lua/oil/constants.lua @@ -2,7 +2,7 @@ local M = {} ---Store entries as a list-like table for maximum space efficiency and retrieval speed. ---We use the constants below to index into the table. ----@alias oil.InternalEntry any[] +---@alias oil.InternalEntry {[1]: integer, [2]: string, [3]: oil.EntryType, [4]: nil|table} -- Indexes into oil.InternalEntry M.FIELD_ID = 1 diff --git a/lua/oil/fs.lua b/lua/oil/fs.lua index a9ae10c..f169c0b 100644 --- a/lua/oil/fs.lua +++ b/lua/oil/fs.lua @@ -218,7 +218,7 @@ M.recursive_delete = function(entry_type, path, cb) local waiting = #entries local complete complete = function(err2) - if err then + if err2 then complete = function() end return inner_cb(err2) end @@ -320,7 +320,7 @@ M.recursive_copy = function(entry_type, src_path, dest_path, cb) local waiting = #entries local complete complete = function(err2) - if err then + if err2 then complete = function() end return inner_cb(err2) end diff --git a/lua/oil/init.lua b/lua/oil/init.lua index 5f780b8..908d6dd 100644 --- a/lua/oil/init.lua +++ b/lua/oil/init.lua @@ -30,8 +30,6 @@ local M = {} ---@field filter_action? fun(action: oil.Action): boolean When present, filter out actions as they are created ---@field filter_error? fun(action: oil.ParseError): boolean When present, filter out errors from parsing a buffer -local load_oil_buffer - ---Get the entry on a specific line (1-indexed) ---@param bufnr integer ---@param lnum integer @@ -224,7 +222,7 @@ M.get_buffer_parent_url = function(bufname, use_oil_parent) if not use_oil_parent then return bufname end - local adapter = config.get_adapter_by_scheme(scheme) + local adapter = assert(config.get_adapter_by_scheme(scheme)) local parent_url if adapter and adapter.get_parent then local adapter_scheme = config.adapter_to_scheme[adapter.name] @@ -344,11 +342,16 @@ end ---Open oil browser in a floating window, or close it if open ---@param dir nil|string When nil, open the parent of the current buffer, or the cwd if current buffer is not a file -M.toggle_float = function(dir) +---@param opts? oil.OpenOpts +---@param cb? fun() Called after the oil buffer is ready +M.toggle_float = function(dir, opts, cb) if vim.w.is_oil_win then M.close() + if cb then + cb() + end else - M.open_float(dir) + M.open_float(dir, opts, cb) end end @@ -545,6 +548,8 @@ M.open_preview = function(opts, callback) end util.get_edit_path(bufnr, entry, function(normalized_url) + local mc = package.loaded["multicursor-nvim"] + local has_multicursors = mc and mc.hasCursors() local is_visual_mode = util.is_visual_mode() if preview_win then if is_visual_mode then @@ -593,7 +598,7 @@ M.open_preview = function(opts, callback) -- If we called open_preview during an autocmd, then the edit command may not trigger the -- BufReadCmd to load the buffer. So we need to do it manually. if util.is_oil_bufnr(filebufnr) then - load_oil_buffer(filebufnr) + M.load_oil_buffer(filebufnr) end vim.api.nvim_set_option_value("previewwindow", true, { scope = "local", win = 0 }) @@ -603,7 +608,10 @@ M.open_preview = function(opts, callback) end vim.w.oil_entry_id = entry.id vim.w.oil_source_win = prev_win - if is_visual_mode then + if has_multicursors then + hack_set_win(prev_win) + mc.restoreCursors() + elseif is_visual_mode then hack_set_win(prev_win) -- Restore the visual selection vim.cmd.normal({ args = { "gv" }, bang = true }) @@ -620,6 +628,7 @@ end ---@field split? "aboveleft"|"belowright"|"topleft"|"botright" Split modifier ---@field tab? boolean Open the buffer in a new tab ---@field close? boolean Close the original oil buffer once selection is made +---@field handle_buffer_callback? fun(buf_id: integer) If defined, all other buffer related options here would be ignored. This callback allows you to take over the process of opening the buffer yourself. ---Select the entry under the cursor ---@param opts nil|oil.SelectOpts @@ -754,18 +763,24 @@ M.select = function(opts, callback) local cmd = "buffer" if opts.tab then vim.cmd.tabnew({ mods = mods }) + -- Make sure the new buffer from tabnew gets cleaned up + vim.bo.bufhidden = "wipe" elseif opts.split then cmd = "sbuffer" end - ---@diagnostic disable-next-line: param-type-mismatch - local ok, err = pcall(vim.cmd, { - cmd = cmd, - args = { filebufnr }, - mods = mods, - }) - -- Ignore swapfile errors - if not ok and err and not err:match("^Vim:E325:") then - vim.api.nvim_echo({ { err, "Error" } }, true, {}) + if opts.handle_buffer_callback ~= nil then + opts.handle_buffer_callback(filebufnr) + else + ---@diagnostic disable-next-line: param-type-mismatch + local ok, err = pcall(vim.cmd, { + cmd = cmd, + args = { filebufnr }, + mods = mods, + }) + -- Ignore swapfile errors + if not ok and err and not err:match("^Vim:E325:") then + vim.api.nvim_echo({ { err, "Error" } }, true, {}) + end end open_next_entry(cb) @@ -818,6 +833,11 @@ end ---@private M._get_highlights = function() return { + { + name = "OilEmpty", + link = "Comment", + desc = "Empty column values", + }, { name = "OilHidden", link = "Comment", @@ -1013,8 +1033,9 @@ local function restore_alt_buf() end end +---@private ---@param bufnr integer -load_oil_buffer = function(bufnr) +M.load_oil_buffer = function(bufnr) local config = require("oil.config") local keymap_util = require("oil.keymap_util") local loading = require("oil.loading") @@ -1117,9 +1138,9 @@ M.setup = function(opts) config.setup(opts) set_colors() - vim.api.nvim_create_user_command("Oil", function(args) + local callback = function(args) local util = require("oil.util") - if args.smods.tab == 1 then + if args.smods.tab > 0 then vim.cmd.tabnew() end local float = false @@ -1152,11 +1173,13 @@ M.setup = function(opts) end end - if not float and (args.smods.vertical or args.smods.split ~= "") then + if not float and (args.smods.vertical or args.smods.horizontal or args.smods.split ~= "") then + local range = args.count > 0 and { args.count } or nil + local cmdargs = { mods = { split = args.smods.split }, range = range } if args.smods.vertical then - vim.cmd.vsplit({ mods = { split = args.smods.split } }) + vim.cmd.vsplit(cmdargs) else - vim.cmd.split({ mods = { split = args.smods.split } }) + vim.cmd.split(cmdargs) end end @@ -1172,7 +1195,12 @@ M.setup = function(opts) open_opts.preview = {} end M[method](path, open_opts) - end, { desc = "Open oil file browser on a directory", nargs = "*", complete = "dir" }) + end + vim.api.nvim_create_user_command( + "Oil", + callback, + { desc = "Open oil file browser on a directory", nargs = "*", complete = "dir", count = true } + ) local aug = vim.api.nvim_create_augroup("Oil", {}) if config.default_file_explorer then @@ -1218,7 +1246,7 @@ M.setup = function(opts) pattern = scheme_pattern, nested = true, callback = function(params) - load_oil_buffer(params.buf) + M.load_oil_buffer(params.buf) end, }) vim.api.nvim_create_autocmd("BufWriteCmd", { @@ -1253,8 +1281,7 @@ M.setup = function(opts) end) vim.cmd.doautocmd({ args = { "BufWritePost", params.file }, mods = { silent = true } }) else - local adapter = config.get_adapter_by_scheme(bufname) - assert(adapter) + local adapter = assert(config.get_adapter_by_scheme(bufname)) adapter.write_file(params.buf) end end, @@ -1281,7 +1308,10 @@ M.setup = function(opts) local util = require("oil.util") local bufname = vim.api.nvim_buf_get_name(0) local scheme = util.parse_url(bufname) - if scheme and config.adapters[scheme] then + local is_oil_buf = scheme and config.adapters[scheme] + -- We want to filter out oil buffers that are not directories (i.e. ssh files) + local is_oil_dir_or_unknown = (vim.bo.filetype == "oil" or vim.bo.filetype == "") + if is_oil_buf and is_oil_dir_or_unknown then local view = require("oil.view") view.maybe_set_cursor() -- While we are in an oil buffer, set the alternate file to the buffer we were in prior to @@ -1389,7 +1419,7 @@ M.setup = function(opts) local util = require("oil.util") local scheme = util.parse_url(params.file) if config.adapters[scheme] and vim.api.nvim_buf_line_count(params.buf) == 1 then - load_oil_buffer(params.buf) + M.load_oil_buffer(params.buf) end end, }) @@ -1398,7 +1428,7 @@ M.setup = function(opts) if maybe_hijack_directory_buffer(bufnr) and vim.v.vim_did_enter == 1 then -- manually call load on a hijacked directory buffer if vim has already entered -- (the BufReadCmd will not trigger) - load_oil_buffer(bufnr) + M.load_oil_buffer(bufnr) end end diff --git a/lua/oil/keymap_util.lua b/lua/oil/keymap_util.lua index 04b8066..8c58738 100644 --- a/lua/oil/keymap_util.lua +++ b/lua/oil/keymap_util.lua @@ -108,7 +108,7 @@ M.show_help = function(keymaps) local highlights = {} local max_line = 1 for _, entry in ipairs(keymap_entries) do - local line = string.format(" %s %s", util.rpad(entry.str, max_lhs), entry.desc) + local line = string.format(" %s %s", util.pad_align(entry.str, max_lhs, "left"), entry.desc) max_line = math.max(max_line, vim.api.nvim_strwidth(line)) table.insert(lines, line) local start = 1 diff --git a/lua/oil/loading.lua b/lua/oil/loading.lua index 35a6bba..6e575c5 100644 --- a/lua/oil/loading.lua +++ b/lua/oil/loading.lua @@ -74,7 +74,8 @@ M.set_loading = function(bufnr, is_loading) M.set_loading(bufnr, false) return end - local lines = { util.lpad("Loading", math.floor(width / 2) - 3), bar_iter() } + local lines = + { util.pad_align("Loading", math.floor(width / 2) - 3, "right"), bar_iter() } util.render_text(bufnr, lines) end) ) diff --git a/lua/oil/lsp/workspace.lua b/lua/oil/lsp/workspace.lua index ac8e180..8e48276 100644 --- a/lua/oil/lsp/workspace.lua +++ b/lua/oil/lsp/workspace.lua @@ -68,24 +68,34 @@ local function get_matching_paths(client, filters, paths) end -- Some language servers use forward slashes as path separators on Windows (LuaLS) - if fs.is_windows then + -- We no longer need this after 0.12: https://github.com/neovim/neovim/commit/322a6d305d088420b23071c227af07b7c1beb41a + if vim.fn.has("nvim-0.12") == 0 and fs.is_windows then glob = glob:gsub("/", "\\") end ---@type string|vim.lpeg.Pattern local glob_to_match = glob if vim.glob and vim.glob.to_lpeg then - -- HACK around https://github.com/neovim/neovim/issues/28931 - -- find alternations and sort them by length to try to match the longest first - if vim.fn.has("nvim-0.11") == 0 then - glob = glob:gsub("{(.*)}", function(s) - local pieces = vim.split(s, ",") - table.sort(pieces, function(a, b) + glob = glob:gsub("{(.-)}", function(s) + local patterns = vim.split(s, ",") + local filtered = {} + for _, pat in ipairs(patterns) do + if pat ~= "" then + table.insert(filtered, pat) + end + end + if #filtered == 0 then + return "" + end + -- HACK around https://github.com/neovim/neovim/issues/28931 + -- find alternations and sort them by length to try to match the longest first + if vim.fn.has("nvim-0.11") == 0 then + table.sort(filtered, function(a, b) return a:len() > b:len() end) - return "{" .. table.concat(pieces, ",") .. "}" - end) - end + end + return "{" .. table.concat(filtered, ",") .. "}" + end) glob_to_match = vim.glob.to_lpeg(glob) end @@ -167,8 +177,13 @@ local function will_file_operation(method, capability_name, files, options) } end, matching_files), } - ---@diagnostic disable-next-line: invisible - local result, err = client.request_sync(method, params, options.timeout_ms or 1000, 0) + local result, err + if vim.fn.has("nvim-0.11") == 1 then + result, err = client:request_sync(method, params, options.timeout_ms or 1000, 0) + else + ---@diagnostic disable-next-line: param-type-mismatch + result, err = client.request_sync(method, params, options.timeout_ms or 1000, 0) + end if result and result.result then if options.apply_edits ~= false then vim.lsp.util.apply_workspace_edit(result.result, client.offset_encoding) @@ -204,8 +219,12 @@ local function did_file_operation(method, capability_name, files) } end, matching_files), } - ---@diagnostic disable-next-line: invisible - client.notify(method, params) + if vim.fn.has("nvim-0.11") == 1 then + client:notify(method, params) + else + ---@diagnostic disable-next-line: param-type-mismatch + client.notify(method, params) + end end end end @@ -280,9 +299,15 @@ function M.will_rename_files(files, options) } end, matching_files), } - local result, err = - ---@diagnostic disable-next-line: invisible - client.request_sync(ms.workspace_willRenameFiles, params, options.timeout_ms or 1000, 0) + local result, err + if vim.fn.has("nvim-0.11") == 1 then + result, err = + client:request_sync(ms.workspace_willRenameFiles, params, options.timeout_ms or 1000, 0) + else + result, err = + ---@diagnostic disable-next-line: param-type-mismatch + client.request_sync(ms.workspace_willRenameFiles, params, options.timeout_ms or 1000, 0) + end if result and result.result then if options.apply_edits ~= false then vim.lsp.util.apply_workspace_edit(result.result, client.offset_encoding) @@ -313,8 +338,12 @@ function M.did_rename_files(files) } end, matching_files), } - ---@diagnostic disable-next-line: invisible - client.notify(ms.workspace_didRenameFiles, params) + if vim.fn.has("nvim-0.11") == 1 then + client:notify(ms.workspace_didRenameFiles, params) + else + ---@diagnostic disable-next-line: param-type-mismatch + client.notify(ms.workspace_didRenameFiles, params) + end end end end diff --git a/lua/oil/mutator/init.lua b/lua/oil/mutator/init.lua index cd0043f..f55957d 100644 --- a/lua/oil/mutator/init.lua +++ b/lua/oil/mutator/init.lua @@ -85,7 +85,7 @@ M.create_actions_from_diffs = function(all_diffs) end end for bufnr, diffs in pairs(all_diffs) do - local adapter = util.get_adapter(bufnr) + local adapter = util.get_adapter(bufnr, true) if not adapter then error("Missing adapter") end @@ -519,7 +519,7 @@ M.try_write_changes = function(confirm, cb) if vim.bo[bufnr].modified then local diffs, errors = parser.parse(bufnr) all_diffs[bufnr] = diffs - local adapter = assert(util.get_adapter(bufnr)) + local adapter = assert(util.get_adapter(bufnr, true)) if adapter.filter_error then errors = vim.tbl_filter(adapter.filter_error, errors) end diff --git a/lua/oil/mutator/parser.lua b/lua/oil/mutator/parser.lua index d2d3590..bb22274 100644 --- a/lua/oil/mutator/parser.lua +++ b/lua/oil/mutator/parser.lua @@ -95,7 +95,7 @@ M.parse_line = function(adapter, line, column_defs) local name = util.split_config(def) local range = { start } local start_len = string.len(rem) - value, rem = columns.parse_col(adapter, rem, def) + value, rem = columns.parse_col(adapter, assert(rem), def) if not rem then return nil, string.format("Parsing %s failed", name) end @@ -156,7 +156,7 @@ M.parse = function(bufnr) ---@type oil.ParseError[] local errors = {} local bufname = vim.api.nvim_buf_get_name(bufnr) - local adapter = util.get_adapter(bufnr) + local adapter = util.get_adapter(bufnr, true) if not adapter then table.insert(errors, { lnum = 0, diff --git a/lua/oil/util.lua b/lua/oil/util.lua index 7be1d5e..0ec1acd 100644 --- a/lua/oil/util.lua +++ b/lua/oil/util.lua @@ -25,46 +25,63 @@ M.escape_filename = function(filename) return ret end -local _url_escape_chars = { - [" "] = "%20", - ["$"] = "%24", - ["&"] = "%26", - ["`"] = "%60", - [":"] = "%3A", - ["<"] = "%3C", - ["="] = "%3D", - [">"] = "%3E", - ["?"] = "%3F", - ["["] = "%5B", - ["\\"] = "%5C", - ["]"] = "%5D", - ["^"] = "%5E", - ["{"] = "%7B", - ["|"] = "%7C", - ["}"] = "%7D", - ["~"] = "%7E", - ["“"] = "%22", - ["‘"] = "%27", - ["+"] = "%2B", - [","] = "%2C", - ["#"] = "%23", - ["%"] = "%25", - ["@"] = "%40", - ["/"] = "%2F", - [";"] = "%3B", +local _url_escape_to_char = { + ["20"] = " ", + ["22"] = "“", + ["23"] = "#", + ["24"] = "$", + ["25"] = "%", + ["26"] = "&", + ["27"] = "‘", + ["2B"] = "+", + ["2C"] = ",", + ["2F"] = "/", + ["3A"] = ":", + ["3B"] = ";", + ["3C"] = "<", + ["3D"] = "=", + ["3E"] = ">", + ["3F"] = "?", + ["40"] = "@", + ["5B"] = "[", + ["5C"] = "\\", + ["5D"] = "]", + ["5E"] = "^", + ["60"] = "`", + ["7B"] = "{", + ["7C"] = "|", + ["7D"] = "}", + ["7E"] = "~", } +local _char_to_url_escape = {} +for k, v in pairs(_url_escape_to_char) do + _char_to_url_escape[v] = "%" .. k +end +-- TODO this uri escape handling is very incomplete + ---@param string string ---@return string M.url_escape = function(string) - return (string:gsub(".", _url_escape_chars)) + return (string:gsub(".", _char_to_url_escape)) +end + +---@param string string +---@return string +M.url_unescape = function(string) + return ( + string:gsub("%%([0-9A-Fa-f][0-9A-Fa-f])", function(seq) + return _url_escape_to_char[seq:upper()] or ("%" .. seq) + end) + ) end ---@param bufnr integer +---@param silent? boolean ---@return nil|oil.Adapter -M.get_adapter = function(bufnr) +M.get_adapter = function(bufnr, silent) local bufname = vim.api.nvim_buf_get_name(bufnr) local adapter = config.get_adapter_by_scheme(bufname) - if not adapter then + if not adapter and not silent then vim.notify_once( string.format("[oil] could not find adapter for buffer '%s://'", bufname), vim.log.levels.ERROR @@ -74,34 +91,28 @@ M.get_adapter = function(bufnr) end ---@param text string ----@param length nil|integer ----@return string -M.rpad = function(text, length) - if not length then - return text +---@param width integer|nil +---@param align oil.ColumnAlign +---@return string padded_text +---@return integer left_padding +M.pad_align = function(text, width, align) + if not width then + return text, 0 end - local textlen = vim.api.nvim_strwidth(text) - local delta = length - textlen - if delta > 0 then - return text .. string.rep(" ", delta) - else - return text + local text_width = vim.api.nvim_strwidth(text) + local total_pad = width - text_width + if total_pad <= 0 then + return text, 0 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 + if align == "right" then + return string.rep(" ", total_pad) .. text, total_pad + elseif align == "center" then + local left_pad = math.floor(total_pad / 2) + local right_pad = total_pad - left_pad + return string.rep(" ", left_pad) .. text .. string.rep(" ", right_pad), left_pad else - return text + return text .. string.rep(" ", total_pad), 0 end end @@ -157,8 +168,10 @@ M.rename_buffer = function(src_bufnr, dest_buf_name) -- 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), {}) + -- Renaming the buffer creates a new buffer with the old name. + -- Find it and try to delete it, but don't if the buffer is in a context + -- where Neovim doesn't allow buffer modifications. + pcall(vim.api.nvim_buf_delete, vim.fn.bufadd(bufname), {}) if altbuf and vim.api.nvim_buf_is_valid(altbuf) then vim.fn.setreg("#", altbuf) end @@ -295,11 +308,15 @@ M.split_config = function(name_or_config) end end +---@alias oil.ColumnAlign "left"|"center"|"right" + ---@param lines oil.TextChunk[][] ---@param col_width integer[] +---@param col_align? oil.ColumnAlign[] ---@return string[] ---@return any[][] List of highlights {group, lnum, col_start, col_end} -M.render_table = function(lines, col_width) +M.render_table = function(lines, col_width, col_align) + col_align = col_align or {} local str_lines = {} local highlights = {} for _, cols in ipairs(lines) do @@ -313,9 +330,12 @@ M.render_table = function(lines, col_width) else text = chunk end - text = M.rpad(text, col_width[i]) + + local unpadded_len = text:len() + local padding + text, padding = M.pad_align(text, col_width[i], col_align[i] or "left") + table.insert(pieces, text) - local col_end = col + text:len() + 1 if hl then if type(hl) == "table" then -- hl has the form { [1]: hl_name, [2]: col_start, [3]: col_end }[] @@ -325,15 +345,15 @@ M.render_table = function(lines, col_width) table.insert(highlights, { sub_hl[1], #str_lines, - col + sub_hl[2], - col + sub_hl[3], + col + padding + sub_hl[2], + col + padding + sub_hl[3], }) end else - table.insert(highlights, { hl, #str_lines, col, col_end }) + table.insert(highlights, { hl, #str_lines, col + padding, col + padding + unpadded_len }) end end - col = col_end + col = col + text:len() + 1 end table.insert(str_lines, table.concat(pieces, " ")) end @@ -346,7 +366,12 @@ 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)) + local group, line, col_start, col_end = unpack(hl) + vim.api.nvim_buf_set_extmark(bufnr, ns, line, col_start, { + end_col = col_end, + hl_group = group, + strict = false, + }) end end @@ -500,10 +525,7 @@ end ---@return oil.Adapter ---@return nil|oil.CrossAdapterAction 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 + local adapter = assert(config.get_adapter_by_scheme(action.url or action.src_url)) if action.dest_url then local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) if adapter ~= dest_adapter then @@ -624,11 +646,7 @@ M.render_text = function(bufnr, text, opts) pcall(vim.api.nvim_buf_set_lines, bufnr, 0, -1, false, lines) vim.bo[bufnr].modifiable = false vim.bo[bufnr].modified = false - 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 + M.set_highlights(bufnr, highlights) end ---Run a function in the context of a full-editor window @@ -656,8 +674,12 @@ end ---@param bufnr integer ---@return boolean M.is_oil_bufnr = function(bufnr) - if vim.bo[bufnr].filetype == "oil" then + local filetype = vim.bo[bufnr].filetype + if filetype == "oil" then return true + elseif filetype ~= "" then + -- If the filetype is set and is NOT "oil", then it's not an oil buffer + return false end local scheme = M.parse_url(vim.api.nvim_buf_get_name(bufnr)) return config.adapters[scheme] or config.adapter_aliases[scheme] @@ -802,11 +824,12 @@ M.send_to_quickfix = function(opts) local action = opts.action == "a" and "a" or "r" if opts.target == "loclist" then vim.fn.setloclist(0, {}, action, { title = qf_title, items = qf_entries }) + vim.cmd.lopen() else vim.fn.setqflist({}, action, { title = qf_title, items = qf_entries }) + vim.cmd.copen() end vim.api.nvim_exec_autocmds("QuickFixCmdPost", {}) - vim.cmd.copen() end ---@return boolean @@ -887,7 +910,7 @@ M.get_edit_path = function(bufnr, entry, callback) local bufname = vim.api.nvim_buf_get_name(bufnr) local scheme, dir = M.parse_url(bufname) - local adapter = M.get_adapter(bufnr) + local adapter = M.get_adapter(bufnr, true) assert(scheme and dir and adapter) local url = scheme .. dir .. entry.name @@ -944,8 +967,12 @@ M.read_file_to_scratch_buffer = function(path, preview_method) vim.bo[bufnr].bufhidden = "wipe" vim.bo[bufnr].buftype = "nofile" - local max_lines = preview_method == "fast_scratch" and vim.o.lines or nil - local has_lines, read_res = pcall(vim.fn.readfile, path, "", max_lines) + local has_lines, read_res + if preview_method == "fast_scratch" then + has_lines, read_res = pcall(vim.fn.readfile, path, "", vim.o.lines) + else + has_lines, read_res = pcall(vim.fn.readfile, path) + end local lines = has_lines and vim.split(table.concat(read_res, "\n"), "\n") or {} local ok = pcall(vim.api.nvim_buf_set_lines, bufnr, 0, -1, false, lines) diff --git a/lua/oil/view.lua b/lua/oil/view.lua index 0e605ac..b3a216e 100644 --- a/lua/oil/view.lua +++ b/lua/oil/view.lua @@ -146,7 +146,7 @@ M.unlock_buffers = function() buffers_locked = false for bufnr in pairs(session) do if vim.api.nvim_buf_is_loaded(bufnr) then - local adapter = util.get_adapter(bufnr) + local adapter = util.get_adapter(bufnr, true) if adapter then vim.bo[bufnr].modifiable = adapter.is_modifiable(bufnr) end @@ -257,21 +257,14 @@ local function get_first_mutable_column_col(adapter, ranges) return min_col end ----Force cursor to be after hidden/immutable columns ----@param mode false|"name"|"editable" -local function constrain_cursor(mode) - if not mode then - return - end +--- @param bufnr integer +--- @param adapter oil.Adapter +--- @param mode false|"name"|"editable" +--- @param cur integer[] +--- @return integer[] | nil +local function calc_constrained_cursor_pos(bufnr, adapter, mode, cur) local parser = require("oil.mutator.parser") - - local adapter = util.get_adapter(0) - if not adapter then - return - end - - local cur = vim.api.nvim_win_get_cursor(0) - local line = vim.api.nvim_buf_get_lines(0, cur[1] - 1, cur[1], true)[1] + local line = vim.api.nvim_buf_get_lines(bufnr, cur[1] - 1, cur[1], true)[1] local column_defs = columns.get_supported_columns(adapter) local result = parser.parse_line(adapter, line, column_defs) if result and result.ranges then @@ -284,7 +277,45 @@ local function constrain_cursor(mode) error(string.format('Unexpected value "%s" for option constrain_cursor', mode)) end if cur[2] < min_col then - vim.api.nvim_win_set_cursor(0, { cur[1], min_col }) + return { cur[1], min_col } + end + end +end + +---Force cursor to be after hidden/immutable columns +---@param bufnr integer +---@param mode false|"name"|"editable" +local function constrain_cursor(bufnr, mode) + if not mode then + return + end + if bufnr ~= vim.api.nvim_get_current_buf() then + return + end + + local adapter = util.get_adapter(bufnr, true) + if not adapter then + return + end + + local mc = package.loaded["multicursor-nvim"] + if mc then + mc.onSafeState(function() + mc.action(function(ctx) + ctx:forEachCursor(function(cursor) + local new_cur = + calc_constrained_cursor_pos(bufnr, adapter, mode, { cursor:line(), cursor:col() - 1 }) + if new_cur then + cursor:setPos({ new_cur[1], new_cur[2] + 1 }) + end + end) + end) + end, { once = true }) + else + local cur = vim.api.nvim_win_get_cursor(0) + local new_cur = calc_constrained_cursor_pos(bufnr, adapter, mode, cur) + if new_cur then + vim.api.nvim_win_set_cursor(0, new_cur) end end end @@ -296,7 +327,7 @@ local function redraw_trash_virtual_text(bufnr) return end local parser = require("oil.mutator.parser") - local adapter = util.get_adapter(bufnr) + local adapter = util.get_adapter(bufnr, true) if not adapter or adapter.name ~= "trash" then return end @@ -406,7 +437,7 @@ M.initialize = function(bufnr) callback = function() -- For some reason the cursor bounces back to its original position, -- so we have to defer the call - vim.schedule_wrap(constrain_cursor)(config.constrain_cursor) + vim.schedule_wrap(constrain_cursor)(bufnr, config.constrain_cursor) end, }) vim.api.nvim_create_autocmd({ "CursorMoved", "ModeChanged" }, { @@ -419,7 +450,7 @@ M.initialize = function(bufnr) return end - constrain_cursor(config.constrain_cursor) + constrain_cursor(bufnr, config.constrain_cursor) if config.preview_win.update_on_cursor_moved then -- Debounce and update the preview window @@ -456,7 +487,7 @@ M.initialize = function(bufnr) end, }) - local adapter = util.get_adapter(bufnr) + local adapter = util.get_adapter(bufnr, true) -- Set up a watcher that will refresh the directory if @@ -583,7 +614,7 @@ local function get_sort_function(adapter, num_entries) end return function(a, b) for _, sort_fn in ipairs(idx_funs) do - local get_sort_value, order = unpack(sort_fn) + local get_sort_value, order = sort_fn[1], sort_fn[2] local a_val = get_sort_value(a) local b_val = get_sort_value(b) if a_val ~= b_val then @@ -616,7 +647,7 @@ local function render_buffer(bufnr, opts) jump_first = false, }) local scheme = util.parse_url(bufname) - local adapter = util.get_adapter(bufnr) + local adapter = util.get_adapter(bufnr, true) if not scheme or not adapter then return false end @@ -637,8 +668,11 @@ local function render_buffer(bufnr, opts) local column_defs = columns.get_supported_columns(scheme) local line_table = {} local col_width = {} - for i in ipairs(column_defs) do + local col_align = {} + for i, col_def in ipairs(column_defs) do col_width[i + 1] = 1 + local _, conf = util.split_config(col_def) + col_align[i + 1] = conf and conf.align or "left" end if M.should_display("..", bufnr) then @@ -661,7 +695,7 @@ local function render_buffer(bufnr, opts) end end - local lines, highlights = util.render_table(line_table, col_width) + local lines, highlights = util.render_table(line_table, col_width, col_align) vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) @@ -690,7 +724,7 @@ local function render_buffer(bufnr, opts) end end - constrain_cursor("name") + constrain_cursor(bufnr, "name") end end end) @@ -877,7 +911,7 @@ M.render_buffer_async = function(bufnr, opts, callback) handle_error(string.format("Could not parse oil url '%s'", bufname)) return end - local adapter = util.get_adapter(bufnr) + local adapter = util.get_adapter(bufnr, true) if not adapter then handle_error(string.format("[oil] no adapter for buffer '%s'", bufname)) return diff --git a/scripts/generate.py b/scripts/generate.py index fac256d..4e02550 100755 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -110,11 +110,16 @@ class ColumnDef: params: List["LuaParam"] = field(default_factory=list) -HL = [ +UNIVERSAL = [ LuaParam( "highlight", "string|fun(value: string): string", "Highlight group, or function that returns a highlight group", + ), + LuaParam( + "align", + '"left"|"center"|"right"', + "Text alignment within the column", ) ] TIME = [ @@ -127,7 +132,7 @@ COL_DEFS = [ False, True, "The type of the entry (file, directory, link, etc)", - HL + UNIVERSAL + [LuaParam("icons", "table", "Mapping of entry type to icon")], ), ColumnDef( @@ -136,7 +141,7 @@ COL_DEFS = [ False, False, "An icon for the entry's type (requires nvim-web-devicons)", - HL + UNIVERSAL + [ LuaParam( "default_file", @@ -151,31 +156,31 @@ COL_DEFS = [ ), ], ), - ColumnDef("size", "files, ssh", False, True, "The size of the file", HL + []), + ColumnDef("size", "files, ssh, s3", False, True, "The size of the file", UNIVERSAL + []), ColumnDef( "permissions", "files, ssh", True, False, "Access permissions of the file", - HL + [], + UNIVERSAL + [], ), ColumnDef( - "ctime", "files", False, True, "Change timestamp of the file", HL + TIME + [] + "ctime", "files", False, True, "Change timestamp of the file", UNIVERSAL + TIME + [] ), ColumnDef( - "mtime", "files", False, True, "Last modified time of the file", HL + TIME + [] + "mtime", "files", False, True, "Last modified time of the file", UNIVERSAL + TIME + [] ), ColumnDef( - "atime", "files", False, True, "Last access time of the file", HL + TIME + [] + "atime", "files", False, True, "Last access time of the file", UNIVERSAL + TIME + [] ), ColumnDef( "birthtime", - "files", + "files, s3", False, True, "The time the file was created", - HL + TIME + [], + UNIVERSAL + TIME + [], ), ] diff --git a/syntax/oil_preview.vim b/syntax/oil_preview.vim index b6c2fab..2f14df9 100644 --- a/syntax/oil_preview.vim +++ b/syntax/oil_preview.vim @@ -2,9 +2,9 @@ if exists("b:current_syntax") finish endif -syn match oilCreate /^CREATE / +syn match oilCreate /^CREATE\( BUCKET\)\? / syn match oilMove /^ MOVE / -syn match oilDelete /^DELETE / +syn match oilDelete /^DELETE\( BUCKET\)\? / syn match oilCopy /^ COPY / syn match oilChange /^CHANGE / " Trash operations diff --git a/tests/files_spec.lua b/tests/files_spec.lua index 66a70d0..4333d80 100644 --- a/tests/files_spec.lua +++ b/tests/files_spec.lua @@ -168,5 +168,6 @@ a.describe("files adapter", function() test_util.wait_for_autocmd("BufReadPost") assert.equals("ruby", vim.bo.filetype) assert.equals(vim.fn.fnamemodify(tmpdir.path, ":p") .. "file.rb", vim.api.nvim_buf_get_name(0)) + assert.equals(tmpdir.path .. "/file.rb", vim.fn.bufname()) end) end) diff --git a/tests/util_spec.lua b/tests/util_spec.lua new file mode 100644 index 0000000..3193842 --- /dev/null +++ b/tests/util_spec.lua @@ -0,0 +1,29 @@ +local util = require("oil.util") +describe("util", function() + it("url_escape", function() + local cases = { + { "foobar", "foobar" }, + { "foo bar", "foo%20bar" }, + { "/foo/bar", "%2Ffoo%2Fbar" }, + } + for _, case in ipairs(cases) do + local input, expected = unpack(case) + local output = util.url_escape(input) + assert.equals(expected, output) + end + end) + + it("url_unescape", function() + local cases = { + { "foobar", "foobar" }, + { "foo%20bar", "foo bar" }, + { "%2Ffoo%2Fbar", "/foo/bar" }, + { "foo%%bar", "foo%%bar" }, + } + for _, case in ipairs(cases) do + local input, expected = unpack(case) + local output = util.url_unescape(input) + assert.equals(expected, output) + end + end) +end)