diff --git a/.envrc b/.envrc index 175de894..d522e346 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,3 @@ +export VIRTUAL_ENV=venv layout python +python -c 'import pyparsing' 2>/dev/null || pip install -r scripts/requirements.txt diff --git a/.gitignore b/.gitignore index d8cb86e1..d427c406 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ luac.out *.zip *.tar.gz +# python bytecode +__pycache__ + # Object files *.o *.os @@ -41,6 +44,10 @@ luac.out .direnv/ .testenv/ +venv/ doc/tags scripts/nvim_doc_tools scripts/nvim-typecheck-action +scripts/benchmark.nvim +perf/tmp/ +profile.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 39811f85..3e7284a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2.13.0](https://github.com/stevearc/oil.nvim/compare/v2.12.2...v2.13.0) (2024-11-11) + + +### Features + +* config option to customize floating window title ([#482](https://github.com/stevearc/oil.nvim/issues/482)) ([5d2dfae](https://github.com/stevearc/oil.nvim/commit/5d2dfae655b9b689bd4017b3bdccd52cbee5b92f)) +* config option to disable lsp file methods ([#477](https://github.com/stevearc/oil.nvim/issues/477)) ([f60bb7f](https://github.com/stevearc/oil.nvim/commit/f60bb7f793477d99ef1acf39e920bf2ca4e644de)) + + +### Bug Fixes + +* actions.preview accepts options ([#497](https://github.com/stevearc/oil.nvim/issues/497)) ([cca1631](https://github.com/stevearc/oil.nvim/commit/cca1631d5ea450c09ba72f3951a9e28105a3632c)) +* add trailing slash to directories on yank_entry ([#504](https://github.com/stevearc/oil.nvim/issues/504)) ([42333bb](https://github.com/stevearc/oil.nvim/commit/42333bb46e34dd47e13927010b1dcd30e6e4ca96)) +* don't deep merge keymaps ([#510](https://github.com/stevearc/oil.nvim/issues/510)) ([709403c](https://github.com/stevearc/oil.nvim/commit/709403ccd6f22d859c2e42c780ab558ae89284d9)) +* guard against nil keymaps ([621f8ba](https://github.com/stevearc/oil.nvim/commit/621f8ba4fa821724e9b646732a26fb2e795fe008)) +* only map ~ for normal mode ([#484](https://github.com/stevearc/oil.nvim/issues/484)) ([ccab9d5](https://github.com/stevearc/oil.nvim/commit/ccab9d5e09e2d0042fbbe5b6bd05e82426247067)) +* sort keymap help entries by description ([#506](https://github.com/stevearc/oil.nvim/issues/506)) ([52cc8a1](https://github.com/stevearc/oil.nvim/commit/52cc8a1fb35ea6ce1df536143add7ce7215c63c0)), closes [#376](https://github.com/stevearc/oil.nvim/issues/376) + ## [2.12.2](https://github.com/stevearc/oil.nvim/compare/v2.12.1...v2.12.2) (2024-09-10) diff --git a/Makefile b/Makefile index 71447f8f..10f01d11 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,66 @@ -.PHONY: all doc test lint fastlint clean +## help: print this help message +.PHONY: help +help: + @echo 'Usage:' + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' +## all: generate docs, lint, and run tests +.PHONY: all all: doc lint test -doc: scripts/nvim_doc_tools - python scripts/main.py generate - python scripts/main.py lint +venv: + python3 -m venv venv + venv/bin/pip install -r scripts/requirements.txt +## doc: generate documentation +.PHONY: doc +doc: scripts/nvim_doc_tools venv + venv/bin/python scripts/main.py generate + venv/bin/python scripts/main.py lint + +## test: run tests +.PHONY: test test: ./run_tests.sh +## lint: run linters and LuaLS typechecking +.PHONY: lint lint: scripts/nvim-typecheck-action fastlint ./scripts/nvim-typecheck-action/typecheck.sh --workdir scripts/nvim-typecheck-action lua -fastlint: scripts/nvim_doc_tools - python scripts/main.py lint +## fastlint: run only fast linters +.PHONY: fastlint +fastlint: scripts/nvim_doc_tools venv + venv/bin/python scripts/main.py lint luacheck lua tests --formatter plain stylua --check lua tests +## profile: use LuaJIT profiler to profile the plugin +.PHONY: profile +profile: scripts/benchmark.nvim + nvim --clean -u perf/bootstrap.lua -c 'lua jit_profile()' + +## flame_profile: create a trace in the chrome profiler format +.PHONY: flame_profile +flame_profile: scripts/benchmark.nvim + nvim --clean -u perf/bootstrap.lua -c 'lua flame_profile()' + +## benchmark: benchmark performance opening directory with many files +.PHONY: benchmark +benchmark: scripts/benchmark.nvim + nvim --clean -u perf/bootstrap.lua -c 'lua benchmark()' + @cat perf/tmp/benchmark.txt + scripts/nvim_doc_tools: git clone https://github.com/stevearc/nvim_doc_tools scripts/nvim_doc_tools scripts/nvim-typecheck-action: git clone https://github.com/stevearc/nvim-typecheck-action scripts/nvim-typecheck-action +scripts/benchmark.nvim: + git clone https://github.com/stevearc/benchmark.nvim scripts/benchmark.nvim + +## clean: reset the repository to a clean state +.PHONY: clean clean: - rm -rf scripts/nvim_doc_tools scripts/nvim-typecheck-action + rm -rf scripts/nvim_doc_tools scripts/nvim-typecheck-action venv .testenv perf/tmp profile.json diff --git a/README.md b/README.md index 46063b2c..f690904e 100644 --- a/README.md +++ b/README.md @@ -214,15 +214,16 @@ require("oil").setup({ show_hidden = false, -- This function defines what is considered a "hidden" file is_hidden_file = function(name, bufnr) - return vim.startswith(name, ".") + local m = name:match("^%.") + return m ~= nil end, -- This function defines what will never be shown, even when `show_hidden` is set is_always_hidden = function(name, bufnr) return false end, - -- Sort file names in a more intuitive order for humans. Is less performant, - -- so you may want to set to false if you work with large directories. - natural_order = true, + -- Sort file names with numbers in a more intuitive order for humans. + -- Can be "fast", true, or false. "fast" will turn it off for large directories. + natural_order = "fast", -- Sort file and directory names case insensitive case_insensitive = false, sort = { @@ -231,6 +232,10 @@ require("oil").setup({ { "type", "asc" }, { "name", "asc" }, }, + -- Customize the highlight group for the file name + highlight_filename = function(entry, is_hidden, is_link_target, is_link_orphan) + return nil + end, }, -- Extra arguments to pass to SCP when moving/copying files over SSH extra_scp_args = {}, @@ -267,8 +272,21 @@ require("oil").setup({ return conf end, }, - -- Configuration for the actions floating preview window - preview = { + -- Configuration for the file preview window + preview_win = { + -- Whether the preview window is automatically updated when the cursor is moved + update_on_cursor_moved = true, + -- How to open the preview window "load"|"scratch"|"fast_scratch" + preview_method = "fast_scratch", + -- A function that returns true to disable preview on a file e.g. to avoid lag + disable_preview = function(filename) + return false + end, + -- Window-local options to use for preview window buffers + win_options = {}, + }, + -- Configuration for the floating action confirmation window + confirmation = { -- Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) -- min_width and max_width can be a single value or a list of mixed integer/float types. -- max_width = {100, 0.8} means "the lesser of 100 columns or 80% of total" @@ -289,8 +307,6 @@ require("oil").setup({ win_options = { winblend = 0, }, - -- Whether the preview window is automatically updated when the cursor is moved - update_on_cursor_moved = true, }, -- Configuration for the floating progress window progress = { @@ -356,8 +372,8 @@ Note that at the moment the ssh adapter does not support Windows machines, and i - [open_float(dir)](doc/api.md#open_floatdir) - [toggle_float(dir)](doc/api.md#toggle_floatdir) - [open(dir)](doc/api.md#opendir) -- [close()](doc/api.md#close) -- [open_preview(opts)](doc/api.md#open_previewopts) +- [close(opts)](doc/api.md#closeopts) +- [open_preview(opts, callback)](doc/api.md#open_previewopts-callback) - [select(opts, callback)](doc/api.md#selectopts-callback) - [save(opts, cb)](doc/api.md#saveopts-cb) - [setup(opts)](doc/api.md#setupopts) diff --git a/doc/api.md b/doc/api.md index e107293f..2f51cddc 100644 --- a/doc/api.md +++ b/doc/api.md @@ -13,8 +13,8 @@ - [open_float(dir)](#open_floatdir) - [toggle_float(dir)](#toggle_floatdir) - [open(dir)](#opendir) -- [close()](#close) -- [open_preview(opts)](#open_previewopts) +- [close(opts)](#closeopts) +- [open_preview(opts, callback)](#open_previewopts-callback) - [select(opts, callback)](#selectopts-callback) - [save(opts, cb)](#saveopts-cb) - [setup(opts)](#setupopts) @@ -119,49 +119,54 @@ Open oil browser for a directory | ----- | ------------- | ------------------------------------------------------------------------------------------- | | dir | `nil\|string` | When nil, open the parent of the current buffer, or the cwd if current buffer is not a file | -## close() +## close(opts) -`close()` \ +`close(opts)` \ Restore the buffer that was present when oil was opened +| Param | Type | Desc | +| ----------------- | -------------------- | --------------------------------------------------- | +| opts | `nil\|oil.CloseOpts` | | +| >exit_if_last_buf | `nil\|boolean` | Exit vim if this oil buffer is the last open buffer | -## open_preview(opts) +## open_preview(opts, callback) -`open_preview(opts)` \ +`open_preview(opts, callback)` \ Preview the entry under the cursor in a split -| Param | Type | Desc | | -| ----- | ------------ | -------------------------------------------------- | ------------------------------------- | -| opts | `nil\|table` | | | -| | vertical | `boolean` | Open the buffer in a vertical split | -| | horizontal | `boolean` | Open the buffer in a horizontal split | -| | split | `"aboveleft"\|"belowright"\|"topleft"\|"botright"` | Split modifier | +| Param | Type | Desc | +| ----------- | ------------------------------------------------------- | ---------------------------------------------- | +| opts | `nil\|oil.OpenPreviewOpts` | | +| >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 | +| callback | `nil\|fun(err: nil\|string)` | Called once the preview window has been opened | ## select(opts, callback) `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 | +| callback | `nil\|fun(err: nil\|string)` | Called once all entries have been opened | ## save(opts, cb) `save(opts, cb)` \ Save all changes -| Param | Type | Desc | | -| ----- | ---------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------- | -| opts | `nil\|table` | | | -| | confirm | `nil\|boolean` | Show confirmation when true, never when false, respect skip_confirm_for_simple_edits if nil | -| cb | `nil\|fun(err: nil\|string)` | Called when mutations complete. | | +| Param | Type | Desc | +| -------- | ---------------------------- | ------------------------------------------------------------------------------------------- | +| opts | `nil\|table` | | +| >confirm | `nil\|boolean` | Show confirmation when true, never when false, respect skip_confirm_for_simple_edits if nil | +| cb | `nil\|fun(err: nil\|string)` | Called when mutations complete. | **Note:**
diff --git a/doc/oil.txt b/doc/oil.txt
index 8e96cf45..63bca11a 100644
--- a/doc/oil.txt
+++ b/doc/oil.txt
@@ -99,15 +99,16 @@ CONFIG                                                                *oil-confi
         show_hidden = false,
         -- This function defines what is considered a "hidden" file
         is_hidden_file = function(name, bufnr)
-          return vim.startswith(name, ".")
+          local m = name:match("^%.")
+          return m ~= nil
         end,
         -- This function defines what will never be shown, even when `show_hidden` is set
         is_always_hidden = function(name, bufnr)
           return false
         end,
-        -- Sort file names in a more intuitive order for humans. Is less performant,
-        -- so you may want to set to false if you work with large directories.
-        natural_order = true,
+        -- Sort file names with numbers in a more intuitive order for humans.
+        -- Can be "fast", true, or false. "fast" will turn it off for large directories.
+        natural_order = "fast",
         -- Sort file and directory names case insensitive
         case_insensitive = false,
         sort = {
@@ -116,6 +117,10 @@ CONFIG                                                                *oil-confi
           { "type", "asc" },
           { "name", "asc" },
         },
+        -- Customize the highlight group for the file name
+        highlight_filename = function(entry, is_hidden, is_link_target, is_link_orphan)
+          return nil
+        end,
       },
       -- Extra arguments to pass to SCP when moving/copying files over SSH
       extra_scp_args = {},
@@ -152,8 +157,21 @@ CONFIG                                                                *oil-confi
           return conf
         end,
       },
-      -- Configuration for the actions floating preview window
-      preview = {
+      -- Configuration for the file preview window
+      preview_win = {
+        -- Whether the preview window is automatically updated when the cursor is moved
+        update_on_cursor_moved = true,
+        -- How to open the preview window "load"|"scratch"|"fast_scratch"
+        preview_method = "fast_scratch",
+        -- A function that returns true to disable preview on a file e.g. to avoid lag
+        disable_preview = function(filename)
+          return false
+        end,
+        -- Window-local options to use for preview window buffers
+        win_options = {},
+      },
+      -- Configuration for the floating action confirmation window
+      confirmation = {
         -- Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%)
         -- min_width and max_width can be a single value or a list of mixed integer/float types.
         -- max_width = {100, 0.8} means "the lesser of 100 columns or 80% of total"
@@ -174,8 +192,6 @@ CONFIG                                                                *oil-confi
         win_options = {
           winblend = 0,
         },
-        -- Whether the preview window is automatically updated when the cursor is moved
-        update_on_cursor_moved = true,
       },
       -- Configuration for the floating progress window
       progress = {
@@ -308,19 +324,25 @@ open({dir})                                                             *oil.ope
       {dir} `nil|string` When nil, open the parent of the current buffer, or the
             cwd if current buffer is not a file
 
-close()                                                                *oil.close*
+close({opts})                                                          *oil.close*
     Restore the buffer that was present when oil was opened
 
+    Parameters:
+      {opts} `nil|oil.CloseOpts`
+          {exit_if_last_buf} `nil|boolean` Exit vim if this oil buffer is the
+                             last open buffer
 
-open_preview({opts})                                            *oil.open_preview*
+open_preview({opts}, {callback})                                *oil.open_preview*
     Preview the entry under the cursor in a split
 
     Parameters:
-      {opts} `nil|table`
-          {vertical}   `boolean` Open the buffer in a vertical split
-          {horizontal} `boolean` Open the buffer in a horizontal split
-          {split}      `"aboveleft"|"belowright"|"topleft"|"botright"` Split
+      {opts}     `nil|oil.OpenPreviewOpts`
+          {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
+      {callback} `nil|fun(err: nil|string)` Called once the preview window has
+                 been opened
 
 select({opts}, {callback})                                            *oil.select*
     Select the entry under the cursor
@@ -503,6 +525,9 @@ change_sort                                                  *actions.change_sor
 close                                                              *actions.close*
     Close oil and restore original buffer
 
+    Parameters:
+      {exit_if_last_buf} `boolean` Exit vim if oil is closed as the last buffer
+
 open_cmdline                                                *actions.open_cmdline*
     Open vim cmdline with current entry as an argument
 
@@ -527,6 +552,12 @@ preview                                                          *actions.previe
     Open the entry under the cursor in a preview window, or close the preview
     window if already open
 
+    Parameters:
+      {horizontal} `boolean` Open the buffer in a horizontal split
+      {split}      `"aboveleft"|"belowright"|"topleft"|"botright"` Split
+                   modifier
+      {vertical}   `boolean` Open the buffer in a vertical split
+
 preview_scroll_down                                  *actions.preview_scroll_down*
     Scroll down in the preview window
 
@@ -580,24 +611,54 @@ yank_entry                                                    *actions.yank_entr
 --------------------------------------------------------------------------------
 HIGHLIGHTS                                                        *oil-highlights*
 
+OilHidden                                                           *hl-OilHidden*
+    Hidden entry in an oil buffer
+
 OilDir                                                                 *hl-OilDir*
     Directory names in an oil buffer
 
+OilDirHidden                                                     *hl-OilDirHidden*
+    Hidden directory names in an oil buffer
+
 OilDirIcon                                                         *hl-OilDirIcon*
     Icon for directories
 
 OilSocket                                                           *hl-OilSocket*
     Socket files in an oil buffer
 
+OilSocketHidden                                               *hl-OilSocketHidden*
+    Hidden socket files in an oil buffer
+
 OilLink                                                               *hl-OilLink*
     Soft links in an oil buffer
 
+OilOrphanLink                                                   *hl-OilOrphanLink*
+    Orphaned soft links in an oil buffer
+
+OilLinkHidden                                                   *hl-OilLinkHidden*
+    Hidden soft links in an oil buffer
+
+OilOrphanLinkHidden                                       *hl-OilOrphanLinkHidden*
+    Hidden orphaned soft links in an oil buffer
+
 OilLinkTarget                                                   *hl-OilLinkTarget*
     The target of a soft link
 
+OilOrphanLinkTarget                                       *hl-OilOrphanLinkTarget*
+    The target of an orphaned soft link
+
+OilLinkTargetHidden                                       *hl-OilLinkTargetHidden*
+    The target of a hidden soft link
+
+OilOrphanLinkTargetHidden                           *hl-OilOrphanLinkTargetHidden*
+    The target of an hidden orphaned soft link
+
 OilFile                                                               *hl-OilFile*
     Normal files in an oil buffer
 
+OilFileHidden                                                   *hl-OilFileHidden*
+    Hidden normal files in an oil buffer
+
 OilCreate                                                           *hl-OilCreate*
     Create action in the oil preview window
 
@@ -651,7 +712,7 @@ Mac:
     (instead of being able to see files that were trashed from a directory).
 
 Windows:
-    Oil does not yet support the Windows trash. PRs are welcome!
+    Oil supports the Windows Recycle Bin. All features should work.
 
 ================================================================================
 vim:tw=80:ts=2:ft=help:norl:syntax=help:
diff --git a/lua/oil/actions.lua b/lua/oil/actions.lua
index 6a2a5ffe..8dabe40a 100644
--- a/lua/oil/actions.lua
+++ b/lua/oil/actions.lua
@@ -69,7 +69,21 @@ M.select_tab = {
 
 M.preview = {
   desc = "Open the entry under the cursor in a preview window, or close the preview window if already open",
-  callback = function()
+  parameters = {
+    vertical = {
+      type = "boolean",
+      desc = "Open the buffer in a vertical split",
+    },
+    horizontal = {
+      type = "boolean",
+      desc = "Open the buffer in a horizontal split",
+    },
+    split = {
+      type = '"aboveleft"|"belowright"|"topleft"|"botright"',
+      desc = "Split modifier",
+    },
+  },
+  callback = function(opts)
     local entry = oil.get_cursor_entry()
     if not entry then
       vim.notify("Could not find entry under cursor", vim.log.levels.ERROR)
@@ -88,7 +102,7 @@ M.preview = {
         return
       end
     end
-    oil.open_preview()
+    oil.open_preview(opts)
   end,
 }
 
@@ -129,7 +143,16 @@ M.parent = {
 
 M.close = {
   desc = "Close oil and restore original buffer",
-  callback = oil.close,
+  callback = function(opts)
+    opts = opts or {}
+    oil.close(opts)
+  end,
+  parameters = {
+    exit_if_last_buf = {
+      type = "boolean",
+      desc = "Exit vim if oil is closed as the last buffer",
+    },
+  },
 }
 
 ---@param cmd string
@@ -352,7 +375,11 @@ M.yank_entry = {
     if not entry or not dir then
       return
     end
-    local path = dir .. entry.name
+    local name = entry.name
+    if entry.type == "directory" then
+      name = name .. "/"
+    end
+    local path = dir .. name
     if opts.modify then
       path = vim.fn.fnamemodify(path, opts.modify)
     end
diff --git a/lua/oil/adapters/files/permissions.lua b/lua/oil/adapters/files/permissions.lua
index cf50b551..6c306a64 100644
--- a/lua/oil/adapters/files/permissions.lua
+++ b/lua/oil/adapters/files/permissions.lua
@@ -1,6 +1,6 @@
 local M = {}
 
----@param exe_modifier nil|false|string
+---@param exe_modifier false|string
 ---@param num integer
 ---@return string
 local function perm_to_str(exe_modifier, num)
diff --git a/lua/oil/columns.lua b/lua/oil/columns.lua
index 40b7d745..d8827922 100644
--- a/lua/oil/columns.lua
+++ b/lua/oil/columns.lua
@@ -19,6 +19,7 @@ local all_columns = {}
 ---@field render_action? fun(action: oil.ChangeAction): string
 ---@field perform_action? fun(action: oil.ChangeAction, callback: fun(err: nil|string))
 ---@field get_sort_value? fun(entry: oil.InternalEntry): number|string
+---@field create_sort_value_factory? fun(num_entries: integer): fun(entry: oil.InternalEntry): number|string
 
 ---@param name string
 ---@param column oil.ColumnDefinition
@@ -292,18 +293,31 @@ M.register("name", {
     error("Do not use the name column. It is for sorting only")
   end,
 
-  get_sort_value = function(entry)
-    local sort_value = entry[FIELD_NAME]
-
-    if config.view_options.natural_order then
-      sort_value = sort_value:gsub("%d+", pad_number)
-    end
-
-    if config.view_options.case_insensitive then
-      sort_value = sort_value:lower()
+  create_sort_value_factory = function(num_entries)
+    if
+      config.view_options.natural_order == false
+      or (config.view_options.natural_order == "fast" and num_entries > 5000)
+    then
+      if config.view_options.case_insensitive then
+        return function(entry)
+          return entry[FIELD_NAME]:lower()
+        end
+      else
+        return function(entry)
+          return entry[FIELD_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)
+        end
+      end
     end
-
-    return sort_value
   end,
 })
 
diff --git a/lua/oil/config.lua b/lua/oil/config.lua
index 5ccde173..0374e43b 100644
--- a/lua/oil/config.lua
+++ b/lua/oil/config.lua
@@ -84,15 +84,16 @@ local default_config = {
     show_hidden = false,
     -- This function defines what is considered a "hidden" file
     is_hidden_file = function(name, bufnr)
-      return vim.startswith(name, ".")
+      local m = name:match("^%.")
+      return m ~= nil
     end,
     -- This function defines what will never be shown, even when `show_hidden` is set
     is_always_hidden = function(name, bufnr)
       return false
     end,
-    -- Sort file names in a more intuitive order for humans. Is less performant,
-    -- so you may want to set to false if you work with large directories.
-    natural_order = true,
+    -- Sort file names with numbers in a more intuitive order for humans.
+    -- Can be "fast", true, or false. "fast" will turn it off for large directories.
+    natural_order = "fast",
     -- Sort file and directory names case insensitive
     case_insensitive = false,
     sort = {
@@ -101,6 +102,10 @@ local default_config = {
       { "type", "asc" },
       { "name", "asc" },
     },
+    -- Customize the highlight group for the file name
+    highlight_filename = function(entry, is_hidden, is_link_target, is_link_orphan)
+      return nil
+    end,
   },
   -- Extra arguments to pass to SCP when moving/copying files over SSH
   extra_scp_args = {},
@@ -137,8 +142,21 @@ local default_config = {
       return conf
     end,
   },
-  -- Configuration for the actions floating preview window
-  preview = {
+  -- Configuration for the file preview window
+  preview_win = {
+    -- Whether the preview window is automatically updated when the cursor is moved
+    update_on_cursor_moved = true,
+    -- How to open the preview window "load"|"scratch"|"fast_scratch"
+    preview_method = "fast_scratch",
+    -- A function that returns true to disable preview on a file e.g. to avoid lag
+    disable_preview = function(filename)
+      return false
+    end,
+    -- Window-local options to use for preview window buffers
+    win_options = {},
+  },
+  -- Configuration for the floating action confirmation window
+  confirmation = {
     -- Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%)
     -- min_width and max_width can be a single value or a list of mixed integer/float types.
     -- max_width = {100, 0.8} means "the lesser of 100 columns or 80% of total"
@@ -159,8 +177,6 @@ local default_config = {
     win_options = {
       winblend = 0,
     },
-    -- Whether the preview window is automatically updated when the cursor is moved
-    update_on_cursor_moved = true,
   },
   -- Configuration for the floating progress window
   progress = {
@@ -195,6 +211,9 @@ default_config.adapters = {
   ["oil-trash://"] = "trash",
 }
 default_config.adapter_aliases = {}
+-- We want the function in the default config for documentation generation, but if we nil it out
+-- here we can get some performance wins
+default_config.view_options.highlight_filename = nil
 
 ---@class oil.Config
 ---@field adapters table Hidden from SetupOpts
@@ -218,7 +237,8 @@ default_config.adapter_aliases = {}
 ---@field extra_scp_args string[]
 ---@field git oil.GitOptions
 ---@field float oil.FloatWindowConfig
----@field preview oil.PreviewWindowConfig
+---@field preview_win oil.PreviewWindowConfig
+---@field confirmation oil.ConfirmationWindowConfig
 ---@field progress oil.ProgressWindowConfig
 ---@field ssh oil.SimpleWindowConfig
 ---@field keymaps_help oil.SimpleWindowConfig
@@ -245,7 +265,8 @@ local M = {}
 ---@field extra_scp_args? string[] Extra arguments to pass to SCP when moving/copying files over SSH
 ---@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? oil.SetupPreviewWindowConfig Configuration for the actions floating preview window
+---@field preview_win? oil.SetupPreviewWindowConfig Configuration for the file preview window
+---@field confirmation? oil.SetupConfirmationWindowConfig Configuration for the floating action confirmation window
 ---@field progress? oil.SetupProgressWindowConfig Configuration for the floating progress window
 ---@field ssh? oil.SetupSimpleWindowConfig Configuration for the floating SSH window
 ---@field keymaps_help? oil.SetupSimpleWindowConfig Configuration for the floating keymaps help window
@@ -264,17 +285,19 @@ local M = {}
 ---@field show_hidden boolean
 ---@field is_hidden_file fun(name: string, bufnr: integer): boolean
 ---@field is_always_hidden fun(name: string, bufnr: integer): boolean
----@field natural_order boolean
+---@field natural_order boolean|"fast"
 ---@field case_insensitive boolean
 ---@field sort oil.SortSpec[]
+---@field highlight_filename? fun(entry: oil.Entry, is_hidden: boolean, is_link_target: boolean, is_link_orphan: boolean): string|nil
 
 ---@class (exact) oil.SetupViewOptions
 ---@field show_hidden? boolean Show files and directories that start with "."
 ---@field is_hidden_file? fun(name: string, bufnr: integer): boolean This function defines what is considered a "hidden" file
 ---@field is_always_hidden? fun(name: string, bufnr: integer): boolean This function defines what will never be shown, even when `show_hidden` is set
----@field natural_order? boolean Sort file names in a more intuitive order for humans. Is less performant, so you may want to set to false if you work with large directories.
+---@field natural_order? boolean|"fast" Sort file names with numbers in a more intuitive order for humans. Can be slow for large directories.
 ---@field case_insensitive? boolean Sort file and directory names case insensitive
 ---@field sort? oil.SortSpec[] Sort order for the file list
+---@field highlight_filename? fun(entry: oil.Entry, is_hidden: boolean, is_link_target: boolean, is_link_orphan: boolean): string|nil Customize the highlight group for the file name
 
 ---@class (exact) oil.SortSpec
 ---@field [1] string
@@ -316,11 +339,26 @@ local M = {}
 ---@field border? string|string[] Window border
 ---@field win_options? table
 
----@class (exact) oil.PreviewWindowConfig : oil.WindowConfig
+---@alias oil.PreviewMethod
+---| '"load"' # Load the previewed file into a buffer
+---| '"scratch"' # Put the text into a scratch buffer to avoid LSP attaching
+---| '"fast_scratch"' # Put only the visible text into a scratch buffer
+
+---@class (exact) oil.PreviewWindowConfig
 ---@field update_on_cursor_moved boolean
+---@field preview_method oil.PreviewMethod
+---@field disable_preview fun(filename: string): boolean
+---@field win_options table
+
+---@class (exact) oil.ConfirmationWindowConfig : oil.WindowConfig
 
----@class (exact) oil.SetupPreviewWindowConfig : oil.SetupWindowConfig
+---@class (exact) oil.SetupPreviewWindowConfig
 ---@field update_on_cursor_moved? boolean Whether the preview window is automatically updated when the cursor is moved
+---@field disable_preview? fun(filename: string): boolean A function that returns true to disable preview on a file e.g. to avoid lag
+---@field preview_method? oil.PreviewMethod How to open the preview window
+---@field win_options? table Window-local options to use for preview window buffers
+
+---@class (exact) oil.SetupConfirmationWindowConfig : oil.SetupWindowConfig
 
 ---@class (exact) oil.ProgressWindowConfig : oil.WindowConfig
 ---@field minimized_border string|string[]
@@ -355,9 +393,26 @@ local M = {}
 ---@field border? string|string[] Window border
 
 M.setup = function(opts)
-  local new_conf = vim.tbl_deep_extend("keep", opts or {}, default_config)
+  opts = opts or {}
+
+  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 {}
+  elseif opts.keymaps then
+    -- We don't want to deep merge the keymaps, we want any keymap defined by the user to override
+    -- everything about the default.
+    for k, v in pairs(opts.keymaps) do
+      new_conf.keymaps[k] = v
+    end
+  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)
+  end
+  -- Backwards compatibility. We renamed the 'preview' config to 'preview_win'
+  if opts.preview and opts.preview.update_on_cursor_moved ~= nil then
+    new_conf.preview_win.update_on_cursor_moved = opts.preview.update_on_cursor_moved
   end
 
   if new_conf.lsp_rename_autosave ~= nil then
diff --git a/lua/oil/init.lua b/lua/oil/init.lua
index 06281668..39ee7537 100644
--- a/lua/oil/init.lua
+++ b/lua/oil/init.lua
@@ -379,8 +379,13 @@ M.open = function(dir)
   update_preview_window()
 end
 
+---@class oil.CloseOpts
+---@field exit_if_last_buf? boolean Exit vim if this oil buffer is the last open buffer
+
 ---Restore the buffer that was present when oil was opened
-M.close = function()
+---@param opts? oil.CloseOpts
+M.close = function(opts)
+  opts = opts or {}
   local mode = vim.api.nvim_get_mode().mode
   -- If we're in operator pending or visual modes, we should cancel that operation and return
   if mode == "no" or mode == "v" or mode == "V" then
@@ -388,7 +393,6 @@ M.close = function()
     vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, true, true), "n", false)
     return
   end
-
   -- If we're in a floating oil window, close it and try to restore focus to the original window
   if vim.w.is_oil_win then
     local original_winid = vim.w.oil_original_win
@@ -411,18 +415,26 @@ M.close = function()
   -- buffer first
   local oilbuf = vim.api.nvim_get_current_buf()
   ok = pcall(vim.cmd.bprev)
+  -- If `bprev` failed, there are no buffers open
   if not ok then
-    -- If `bprev` failed, there are no buffers open so we should create a new one with enew
-    vim.cmd.enew()
+    -- either exit or create a new blank buffer
+    if opts.exit_if_last_buf then
+      vim.cmd.quit()
+    else
+      vim.cmd.enew()
+    end
   end
   vim.api.nvim_buf_delete(oilbuf, { force = true })
 end
 
+---@class oil.OpenPreviewOpts
+---@field vertical? boolean Open the buffer in a vertical split
+---@field horizontal? boolean Open the buffer in a horizontal split
+---@field split? "aboveleft"|"belowright"|"topleft"|"botright" Split modifier
+
 ---Preview the entry under the cursor in a split
----@param opts nil|table
----    vertical boolean Open the buffer in a vertical split
----    horizontal boolean Open the buffer in a horizontal split
----    split "aboveleft"|"belowright"|"topleft"|"botright" Split modifier
+---@param opts? oil.OpenPreviewOpts
+---@param callback? fun(err: nil|string) Called once the preview window has been opened
 M.open_preview = function(opts, callback)
   opts = opts or {}
   local config = require("oil.config")
@@ -527,14 +539,28 @@ M.open_preview = function(opts, callback)
       end
     end
 
-    local filebufnr = vim.fn.bufadd(normalized_url)
     local entry_is_file = not vim.endswith(normalized_url, "/")
-
-    -- If we're previewing a file that hasn't been opened yet, make sure it gets deleted after
-    -- we close the window
-    if entry_is_file and vim.fn.bufloaded(filebufnr) == 0 then
+    local filebufnr
+    if
+      entry_is_file
+      and config.preview_win.preview_method ~= "load"
+      and not util.file_matches_bufreadcmd(normalized_url)
+    then
+      filebufnr =
+        util.read_file_to_scratch_buffer(normalized_url, config.preview_win.preview_method)
+    elseif entry_is_file and config.preview_win.disable_preview(normalized_url) then
+      filebufnr = vim.api.nvim_create_buf(false, true)
       vim.bo[filebufnr].bufhidden = "wipe"
-      vim.b[filebufnr].oil_preview_buffer = true
+      vim.bo[filebufnr].buftype = "nofile"
+      util.render_text(filebufnr, "Preview disabled", { winid = preview_win })
+    end
+
+    if not filebufnr then
+      filebufnr = vim.fn.bufadd(normalized_url)
+      if entry_is_file and vim.fn.bufloaded(filebufnr) == 0 then
+        vim.bo[filebufnr].bufhidden = "wipe"
+        vim.b[filebufnr].oil_preview_buffer = true
+      end
     end
 
     ---@diagnostic disable-next-line: param-type-mismatch
@@ -549,6 +575,9 @@ M.open_preview = function(opts, callback)
     end
 
     vim.api.nvim_set_option_value("previewwindow", true, { scope = "local", win = 0 })
+    for k, v in pairs(config.preview_win.win_options) do
+      vim.api.nvim_set_option_value(k, v, { scope = "local", win = preview_win })
+    end
     vim.w.oil_entry_id = entry.id
     vim.w.oil_source_win = prev_win
     if is_visual_mode then
@@ -774,11 +803,21 @@ end
 ---@private
 M._get_highlights = function()
   return {
+    {
+      name = "OilHidden",
+      link = "Comment",
+      desc = "Hidden entry in an oil buffer",
+    },
     {
       name = "OilDir",
       link = "Directory",
       desc = "Directory names in an oil buffer",
     },
+    {
+      name = "OilDirHidden",
+      link = "OilHidden",
+      desc = "Hidden directory names in an oil buffer",
+    },
     {
       name = "OilDirIcon",
       link = "OilDir",
@@ -789,21 +828,61 @@ M._get_highlights = function()
       link = "Keyword",
       desc = "Socket files in an oil buffer",
     },
+    {
+      name = "OilSocketHidden",
+      link = "OilHidden",
+      desc = "Hidden socket files in an oil buffer",
+    },
     {
       name = "OilLink",
       link = nil,
       desc = "Soft links in an oil buffer",
     },
+    {
+      name = "OilOrphanLink",
+      link = nil,
+      desc = "Orphaned soft links in an oil buffer",
+    },
+    {
+      name = "OilLinkHidden",
+      link = "OilHidden",
+      desc = "Hidden soft links in an oil buffer",
+    },
+    {
+      name = "OilOrphanLinkHidden",
+      link = "OilLinkHidden",
+      desc = "Hidden orphaned soft links in an oil buffer",
+    },
     {
       name = "OilLinkTarget",
       link = "Comment",
       desc = "The target of a soft link",
     },
+    {
+      name = "OilOrphanLinkTarget",
+      link = "DiagnosticError",
+      desc = "The target of an orphaned soft link",
+    },
+    {
+      name = "OilLinkTargetHidden",
+      link = "OilHidden",
+      desc = "The target of a hidden soft link",
+    },
+    {
+      name = "OilOrphanLinkTargetHidden",
+      link = "OilOrphanLinkTarget",
+      desc = "The target of an hidden orphaned soft link",
+    },
     {
       name = "OilFile",
       link = nil,
       desc = "Normal files in an oil buffer",
     },
+    {
+      name = "OilFileHidden",
+      link = "OilHidden",
+      desc = "Hidden normal files in an oil buffer",
+    },
     {
       name = "OilCreate",
       link = "DiagnosticInfo",
diff --git a/lua/oil/keymap_util.lua b/lua/oil/keymap_util.lua
index b94581cb..b62756eb 100644
--- a/lua/oil/keymap_util.lua
+++ b/lua/oil/keymap_util.lua
@@ -78,31 +78,30 @@ M.show_help = function(keymaps)
     end
   end
 
-  local col_left = {}
-  local col_desc = {}
   local max_lhs = 1
+  local keymap_entries = {}
   for k, rhs in pairs(keymaps) do
     local all_lhs = lhs_to_all_lhs[k]
     if all_lhs then
       local _, opts = resolve(rhs)
       local keystr = table.concat(all_lhs, "/")
       max_lhs = math.max(max_lhs, vim.api.nvim_strwidth(keystr))
-      table.insert(col_left, { str = keystr, all_lhs = all_lhs })
-      table.insert(col_desc, opts.desc or "")
+      table.insert(keymap_entries, { str = keystr, all_lhs = all_lhs, desc = opts.desc or "" })
     end
   end
+  table.sort(keymap_entries, function(a, b)
+    return a.desc < b.desc
+  end)
 
   local lines = {}
   local highlights = {}
   local max_line = 1
-  for i = 1, #col_left do
-    local left = col_left[i]
-    local desc = col_desc[i]
-    local line = string.format(" %s   %s", util.rpad(left.str, max_lhs), desc)
+  for _, entry in ipairs(keymap_entries) do
+    local line = string.format(" %s   %s", util.rpad(entry.str, max_lhs), entry.desc)
     max_line = math.max(max_line, vim.api.nvim_strwidth(line))
     table.insert(lines, line)
     local start = 1
-    for _, key in ipairs(left.all_lhs) do
+    for _, key in ipairs(entry.all_lhs) do
       local keywidth = vim.api.nvim_strwidth(key)
       table.insert(highlights, { "Special", #lines, start, start + keywidth })
       start = start + keywidth + 1
diff --git a/lua/oil/layout.lua b/lua/oil/layout.lua
index f22d26af..8ed7b4e6 100644
--- a/lua/oil/layout.lua
+++ b/lua/oil/layout.lua
@@ -182,6 +182,11 @@ M.split_window = function(winid, direction, gap)
   return dim_root, dim_new
 end
 
+---@param desired_width integer
+---@param desired_height integer
+---@param opts table
+---@return integer width
+---@return integer height
 M.calculate_dims = function(desired_width, desired_height, opts)
   local width = M.calculate_width(desired_width, opts)
   local height = M.calculate_height(desired_height, opts)
diff --git a/lua/oil/mutator/preview.lua b/lua/oil/mutator/confirmation.lua
similarity index 97%
rename from lua/oil/mutator/preview.lua
rename to lua/oil/mutator/confirmation.lua
index 3f8d87dc..4e86abc3 100644
--- a/lua/oil/mutator/preview.lua
+++ b/lua/oil/mutator/confirmation.lua
@@ -91,7 +91,7 @@ M.show = vim.schedule_wrap(function(actions, should_confirm, cb)
   table.insert(lines, "")
 
   -- Create the floating window
-  local width, height = layout.calculate_dims(max_line_width, #lines + 1, config.preview)
+  local width, height = layout.calculate_dims(max_line_width, #lines + 1, config.confirmation)
   local ok, winid = pcall(vim.api.nvim_open_win, bufnr, true, {
     relative = "editor",
     width = width,
@@ -100,7 +100,7 @@ M.show = vim.schedule_wrap(function(actions, should_confirm, cb)
     col = math.floor((layout.get_editor_width() - width) / 2),
     zindex = 152, -- render on top of the floating window title
     style = "minimal",
-    border = config.preview.border,
+    border = config.confirmation.border,
   })
   if not ok then
     vim.notify(string.format("Error showing oil preview window: %s", winid), vim.log.levels.ERROR)
@@ -108,7 +108,7 @@ M.show = vim.schedule_wrap(function(actions, should_confirm, cb)
   end
   vim.bo[bufnr].filetype = "oil_preview"
   vim.bo[bufnr].syntax = "oil_preview"
-  for k, v in pairs(config.preview.win_options) do
+  for k, v in pairs(config.confirmation.win_options) do
     vim.api.nvim_set_option_value(k, v, { scope = "local", win = winid })
   end
 
@@ -155,7 +155,7 @@ M.show = vim.schedule_wrap(function(actions, should_confirm, cb)
     vim.api.nvim_create_autocmd("VimResized", {
       callback = function()
         if vim.api.nvim_win_is_valid(winid) then
-          width, height = layout.calculate_dims(max_line_width, #lines, config.preview)
+          width, height = layout.calculate_dims(max_line_width, #lines, config.confirmation)
           vim.api.nvim_win_set_config(winid, {
             relative = "editor",
             width = width,
diff --git a/lua/oil/mutator/init.lua b/lua/oil/mutator/init.lua
index 8dfd4b05..f15c0692 100644
--- a/lua/oil/mutator/init.lua
+++ b/lua/oil/mutator/init.lua
@@ -3,12 +3,12 @@ local Trie = require("oil.mutator.trie")
 local cache = require("oil.cache")
 local columns = require("oil.columns")
 local config = require("oil.config")
+local confirmation = require("oil.mutator.confirmation")
 local constants = require("oil.constants")
 local fs = require("oil.fs")
 local lsp_helpers = require("oil.lsp.helpers")
 local oil = require("oil")
 local parser = require("oil.mutator.parser")
-local preview = require("oil.mutator.preview")
 local util = require("oil.util")
 local view = require("oil.view")
 local M = {}
@@ -564,7 +564,7 @@ M.try_write_changes = function(confirm, cb)
   end
 
   local actions = M.create_actions_from_diffs(all_diffs)
-  preview.show(actions, confirm, function(proceed)
+  confirmation.show(actions, confirm, function(proceed)
     if not proceed then
       unlock()
       cb("Canceled")
diff --git a/lua/oil/shell.lua b/lua/oil/shell.lua
index 2c401efc..b04b27be 100644
--- a/lua/oil/shell.lua
+++ b/lua/oil/shell.lua
@@ -26,7 +26,8 @@ M.run = function(cmd, opts, callback)
           if err == "" then
             err = "Unknown error"
           end
-          callback(err)
+          local cmd_str = type(cmd) == "string" and cmd or table.concat(cmd, " ")
+          callback(string.format("Error running command '%s'\n%s", cmd_str, err))
         end
       end),
     })
diff --git a/lua/oil/util.lua b/lua/oil/util.lua
index f5c63e2a..441421b9 100644
--- a/lua/oil/util.lua
+++ b/lua/oil/util.lua
@@ -347,7 +347,8 @@ M.addslash = function(path, os_slash)
     slash = "\\"
   end
 
-  if not vim.endswith(path, slash) then
+  local endslash = path:match(slash .. "$")
+  if not endslash then
     return path .. slash
   else
     return path
@@ -896,4 +897,81 @@ M.get_icon_provider = function()
   end
 end
 
+---Read a buffer into a scratch buffer and apply syntactic highlighting when possible
+---@param path string The path to the file to read
+---@param preview_method oil.PreviewMethod
+---@return nil|integer
+M.read_file_to_scratch_buffer = function(path, preview_method)
+  local bufnr = vim.api.nvim_create_buf(false, true)
+  if bufnr == 0 then
+    return
+  end
+
+  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 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)
+  if not ok then
+    return
+  end
+  local ft = vim.filetype.match({ filename = path, buf = bufnr })
+  if ft and ft ~= "" then
+    local lang = vim.treesitter.language.get_lang(ft)
+    if not pcall(vim.treesitter.start, bufnr, lang) then
+      vim.bo[bufnr].syntax = ft
+    else
+    end
+  end
+
+  -- Replace the scratch buffer with a real buffer if we enter it
+  vim.api.nvim_create_autocmd("BufEnter", {
+    desc = "oil.nvim replace scratch buffer with real buffer",
+    buffer = bufnr,
+    callback = function()
+      local winid = vim.api.nvim_get_current_win()
+      -- Have to schedule this so all the FileType, etc autocmds will fire
+      vim.schedule(function()
+        if vim.api.nvim_get_current_win() == winid then
+          vim.cmd.edit({ args = { path } })
+
+          -- If we're still in a preview window, make sure this buffer still gets treated as a
+          -- preview
+          if vim.wo.previewwindow then
+            vim.bo.bufhidden = "wipe"
+            vim.b.oil_preview_buffer = true
+          end
+        end
+      end)
+    end,
+  })
+
+  return bufnr
+end
+
+local _regcache = {}
+---Check if a file matches a BufReadCmd autocmd
+---@param filename string
+---@return boolean
+M.file_matches_bufreadcmd = function(filename)
+  local autocmds = vim.api.nvim_get_autocmds({
+    event = "BufReadCmd",
+  })
+  for _, au in ipairs(autocmds) do
+    local pat = _regcache[au.pattern]
+    if not pat then
+      pat = vim.fn.glob2regpat(au.pattern)
+      _regcache[au.pattern] = pat
+    end
+
+    if vim.fn.match(filename, pat) >= 0 then
+      return true
+    end
+  end
+  return false
+end
+
 return M
diff --git a/lua/oil/view.lua b/lua/oil/view.lua
index 89e4b8fc..15e50228 100644
--- a/lua/oil/view.lua
+++ b/lua/oil/view.lua
@@ -19,10 +19,16 @@ local last_cursor_entry = {}
 
 ---@param name string
 ---@param bufnr integer
----@return boolean
+---@return boolean display
+---@return boolean is_hidden Whether the file is classified as a hidden file
 M.should_display = function(name, bufnr)
-  return not config.view_options.is_always_hidden(name, bufnr)
-    and (config.view_options.show_hidden or not config.view_options.is_hidden_file(name, bufnr))
+  if config.view_options.is_always_hidden(name, bufnr) then
+    return false, true
+  else
+    local is_hidden = config.view_options.is_hidden_file(name, bufnr)
+    local display = config.view_options.show_hidden or not is_hidden
+    return display, is_hidden
+  end
 end
 
 ---@param bufname string
@@ -179,9 +185,18 @@ end
 
 M.set_win_options = function()
   local winid = vim.api.nvim_get_current_win()
+
+  -- work around https://github.com/neovim/neovim/pull/27422
+  vim.api.nvim_set_option_value("foldmethod", "manual", { scope = "local", win = winid })
+
   for k, v in pairs(config.win_options) do
     vim.api.nvim_set_option_value(k, v, { scope = "local", win = winid })
   end
+  if vim.wo[winid].previewwindow then -- apply preview window options last
+    for k, v in pairs(config.preview_win.win_options) do
+      vim.api.nvim_set_option_value(k, v, { scope = "local", win = winid })
+    end
+  end
 end
 
 ---Get a list of visible oil buffers and a list of hidden oil buffers
@@ -407,13 +422,13 @@ M.initialize = function(bufnr)
 
       constrain_cursor()
 
-      if config.preview.update_on_cursor_moved then
+      if config.preview_win.update_on_cursor_moved then
         -- Debounce and update the preview window
         if timer then
           timer:again()
           return
         end
-        timer = vim.loop.new_timer()
+        timer = uv.new_timer()
         if not timer then
           return
         end
@@ -532,8 +547,9 @@ M.initialize = function(bufnr)
 end
 
 ---@param adapter oil.Adapter
+---@param num_entries integer
 ---@return fun(a: oil.InternalEntry, b: oil.InternalEntry): boolean
-local function get_sort_function(adapter)
+local function get_sort_function(adapter, num_entries)
   local idx_funs = {}
   local sort_config = config.view_options.sort
 
@@ -555,7 +571,9 @@ local function get_sort_function(adapter)
       )
     end
     local col = columns.get_column(adapter, col_name)
-    if col and col.get_sort_value then
+    if col and col.create_sort_value_factory then
+      table.insert(idx_funs, { col.create_sort_value_factory(num_entries), order })
+    elseif col and col.get_sort_value then
       table.insert(idx_funs, { col.get_sort_value, order })
     else
       vim.notify_once(
@@ -606,7 +624,10 @@ local function render_buffer(bufnr, opts)
   local entries = cache.list_url(bufname)
   local entry_list = vim.tbl_values(entries)
 
-  table.sort(entry_list, get_sort_function(adapter))
+  -- Only sort the entries once we have them all
+  if not vim.b[bufnr].oil_rendering then
+    table.sort(entry_list, get_sort_function(adapter, #entry_list))
+  end
 
   local jump_idx
   if opts.jump_first then
@@ -622,20 +643,21 @@ local function render_buffer(bufnr, opts)
   end
 
   if M.should_display("..", bufnr) then
-    local cols = M.format_entry_cols({ 0, "..", "directory" }, column_defs, col_width, adapter)
+    local cols =
+      M.format_entry_cols({ 0, "..", "directory" }, column_defs, col_width, adapter, true)
     table.insert(line_table, cols)
   end
 
   for _, entry in ipairs(entry_list) do
-    if M.should_display(entry[FIELD_NAME], bufnr) then
-      local cols = M.format_entry_cols(entry, column_defs, col_width, adapter)
+    local should_display, is_hidden = M.should_display(entry[FIELD_NAME], bufnr)
+    if should_display then
+      local cols = M.format_entry_cols(entry, column_defs, col_width, adapter, is_hidden)
       table.insert(line_table, cols)
 
       local name = entry[FIELD_NAME]
       if seek_after_render == name then
         seek_after_render_found = true
         jump_idx = #line_table
-        M.set_last_cursor(bufname, nil)
       end
     end
   end
@@ -673,15 +695,42 @@ local function render_buffer(bufnr, opts)
   return seek_after_render_found
 end
 
+---@param name string
+---@param meta? table
+---@return string filename
+---@return string|nil link_target
+local function get_link_text(name, meta)
+  local link_text
+  if meta then
+    if meta.link_stat and meta.link_stat.type == "directory" then
+      name = name .. "/"
+    end
+
+    if meta.link then
+      link_text = "-> " .. meta.link
+      if meta.link_stat and meta.link_stat.type == "directory" then
+        link_text = util.addslash(link_text)
+      end
+    end
+  end
+
+  return name, link_text
+end
+
 ---@private
 ---@param entry oil.InternalEntry
 ---@param column_defs table[]
 ---@param col_width integer[]
 ---@param adapter oil.Adapter
+---@param is_hidden boolean
 ---@return oil.TextChunk[]
-M.format_entry_cols = function(entry, column_defs, col_width, adapter)
+M.format_entry_cols = function(entry, column_defs, col_width, adapter, is_hidden)
   local name = entry[FIELD_NAME]
   local meta = entry[FIELD_META]
+  local hl_suffix = ""
+  if is_hidden then
+    hl_suffix = "Hidden"
+  end
   if meta and meta.display_name then
     name = meta.display_name
   end
@@ -700,32 +749,55 @@ M.format_entry_cols = function(entry, column_defs, col_width, adapter)
   end
   -- Always add the entry name at the end
   local entry_type = entry[FIELD_TYPE]
-  if entry_type == "directory" then
-    table.insert(cols, { name .. "/", "OilDir" })
-  elseif entry_type == "socket" then
-    table.insert(cols, { name, "OilSocket" })
-  elseif entry_type == "link" then
-    local link_text
-    if meta then
-      if meta.link_stat and meta.link_stat.type == "directory" then
-        name = name .. "/"
+
+  local get_custom_hl = config.view_options.highlight_filename
+  local link_name, link_name_hl, link_target, link_target_hl
+  if get_custom_hl then
+    local external_entry = util.export_entry(entry)
+
+    if entry_type == "link" then
+      link_name, link_target = get_link_text(name, meta)
+      local is_orphan = not (meta and meta.link_stat)
+      link_name_hl = get_custom_hl(external_entry, is_hidden, false, is_orphan)
+
+      if link_target then
+        link_target_hl = get_custom_hl(external_entry, is_hidden, true, is_orphan)
       end
 
-      if meta.link then
-        link_text = "->" .. " " .. meta.link
-        if meta.link_stat and meta.link_stat.type == "directory" then
-          link_text = util.addslash(link_text)
-        end
+      -- intentional fallthrough
+    else
+      local hl = get_custom_hl(external_entry, is_hidden, false, false)
+      if hl then
+        table.insert(cols, { name, hl })
+        return cols
       end
     end
+  end
 
-    table.insert(cols, { name, "OilLink" })
-    if link_text then
-      table.insert(cols, { link_text, "OilLinkTarget" })
+  if entry_type == "directory" then
+    table.insert(cols, { name .. "/", "OilDir" .. hl_suffix })
+  elseif entry_type == "socket" then
+    table.insert(cols, { name, "OilSocket" .. hl_suffix })
+  elseif entry_type == "link" then
+    if not link_name then
+      link_name, link_target = get_link_text(name, meta)
+    end
+    local is_orphan = not (meta and meta.link_stat)
+    if not link_name_hl then
+      link_name_hl = (is_orphan and "OilOrphanLink" or "OilLink") .. hl_suffix
+    end
+    table.insert(cols, { link_name, link_name_hl })
+
+    if link_target then
+      if not link_target_hl then
+        link_target_hl = (is_orphan and "OilOrphanLinkTarget" or "OilLinkTarget") .. hl_suffix
+      end
+      table.insert(cols, { link_target, link_target_hl })
     end
   else
-    table.insert(cols, { name, "OilFile" })
+    table.insert(cols, { name, "OilFile" .. hl_suffix })
   end
+
   return cols
 end
 
@@ -814,6 +886,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
     vim.b[bufnr].oil_rendering = false
     loading.set_loading(bufnr, false)
     render_buffer(bufnr, { jump = true })
+    M.set_last_cursor(bufname, nil)
     vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
     vim.bo[bufnr].modifiable = not buffers_locked and adapter.is_modifiable(bufnr)
     if callback then
@@ -838,6 +911,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
   end
 
   cache.begin_update_url(bufname)
+  local num_iterations = 0
   adapter.list(bufname, get_used_columns(), function(err, entries, fetch_more)
     loading.set_loading(bufnr, false)
     if err then
@@ -854,11 +928,13 @@ M.render_buffer_async = function(bufnr, opts, callback)
       local now = uv.hrtime() / 1e6
       local delta = now - start_ms
       -- If we've been chugging for more than 40ms, go ahead and render what we have
-      if delta > 40 then
+      if (delta > 25 and num_iterations < 1) or delta > 500 then
+        num_iterations = num_iterations + 1
         start_ms = now
         vim.schedule(function()
           seek_after_render_found =
             render_buffer(bufnr, { jump = not seek_after_render_found, jump_first = first })
+          start_ms = uv.hrtime() / 1e6
         end)
       end
       first = false
diff --git a/perf/bootstrap.lua b/perf/bootstrap.lua
new file mode 100644
index 00000000..5f10c065
--- /dev/null
+++ b/perf/bootstrap.lua
@@ -0,0 +1,63 @@
+vim.opt.runtimepath:prepend("scripts/benchmark.nvim")
+vim.opt.runtimepath:prepend(".")
+
+local bm = require("benchmark")
+bm.sandbox()
+
+---@module 'oil'
+---@type oil.SetupOpts
+local setup_opts = {
+  -- columns = { "icon", "permissions", "size", "mtime" },
+}
+
+local DIR_SIZE = tonumber(vim.env.DIR_SIZE) or 100000
+local ITERATIONS = tonumber(vim.env.ITERATIONS) or 10
+local WARM_UP = tonumber(vim.env.WARM_UP) or 1
+local OUTLIERS = tonumber(vim.env.OUTLIERS) or math.floor(ITERATIONS / 10)
+local TEST_DIR = "perf/tmp/test_" .. DIR_SIZE
+
+vim.fn.mkdir(TEST_DIR, "p")
+require("benchmark.files").create_files(TEST_DIR, "file %d.txt", DIR_SIZE)
+
+function _G.jit_profile()
+  require("oil").setup(setup_opts)
+  local finish = bm.jit_profile({ filename = TEST_DIR .. "/profile.txt" })
+  bm.wait_for_user_event("OilEnter", function()
+    finish()
+  end)
+  require("oil").open(TEST_DIR)
+end
+
+function _G.flame_profile()
+  local start, stop = bm.flame_profile({
+    pattern = "oil*",
+    filename = "profile.json",
+  })
+  require("oil").setup(setup_opts)
+  start()
+  bm.wait_for_user_event("OilEnter", function()
+    stop(function()
+      vim.cmd.qall({ mods = { silent = true } })
+    end)
+  end)
+  require("oil").open(TEST_DIR)
+end
+
+function _G.benchmark()
+  require("oil").setup(setup_opts)
+  bm.run({ title = "oil.nvim", iterations = ITERATIONS, warm_up = WARM_UP }, function(callback)
+    bm.wait_for_user_event("OilEnter", callback)
+    require("oil").open(TEST_DIR)
+  end, function(times)
+    local avg = bm.avg(times, { trim_outliers = OUTLIERS })
+    local std_dev = bm.std_dev(times, { trim_outliers = OUTLIERS })
+    local lines = {
+      table.concat(vim.tbl_map(bm.format_time, times), " "),
+      string.format("Average: %s", bm.format_time(avg)),
+      string.format("Std deviation: %s", bm.format_time(std_dev)),
+    }
+
+    vim.fn.writefile(lines, "perf/tmp/benchmark.txt")
+    vim.cmd.qall({ mods = { silent = true } })
+  end)
+end
diff --git a/scripts/generate.py b/scripts/generate.py
index 5cc5fe35..a20ad532 100755
--- a/scripts/generate.py
+++ b/scripts/generate.py
@@ -366,7 +366,7 @@ def get_trash_vimdoc() -> "VimdocSection":
     (instead of being able to see files that were trashed from a directory).
 
 Windows:
-    Oil does not yet support the Windows trash. PRs are welcome!
+    Oil supports the Windows Recycle Bin. All features should work.
 """
     )
     return section
diff --git a/scripts/requirements.txt b/scripts/requirements.txt
new file mode 100644
index 00000000..2c6271fd
--- /dev/null
+++ b/scripts/requirements.txt
@@ -0,0 +1,4 @@
+pyparsing==3.0.9
+black
+isort
+mypy
diff --git a/tests/parser_spec.lua b/tests/parser_spec.lua
index 527e8217..9884ca1b 100644
--- a/tests/parser_spec.lua
+++ b/tests/parser_spec.lua
@@ -90,7 +90,7 @@ describe("parser", function()
     local file = test_adapter.test_set("/foo/a.txt", "file")
     vim.cmd.edit({ args = { "oil-test:///foo/" } })
     local bufnr = vim.api.nvim_get_current_buf()
-    local cols = view.format_entry_cols(file, {}, {}, test_adapter)
+    local cols = view.format_entry_cols(file, {}, {}, test_adapter, false)
     local lines = util.render_table({ cols }, {})
     table.insert(lines, "")
     table.insert(lines, "     ")