From bf917a503a38a4af9072f4473b340720d1d45851 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:31:18 -0500 Subject: [PATCH] feat(runtime): replace netrw with a very small script #39723 Problem: `:edit ` and `nvim ` currently rely on netrw to show local directory contents. Solution: - Provide `filetype=directory`. - Introduce dir.lua, a small plugin that provides directory listing, opening items, parent navigation, and refresh. - `netrw` remains available for `:Explore`, remote paths, archives, and file operations. To continue Co-authored-by: Justin M. Keyes --- runtime/doc/news.txt | 2 + runtime/doc/plugins.txt | 24 ++ runtime/doc/usr_22.txt | 99 +------ runtime/doc/vim_diff.txt | 2 + runtime/lua/nvim/dir.lua | 241 ++++++++++++++++ runtime/pack/dist/opt/netrw/doc/netrw.txt | 29 +- runtime/plugin/dir.lua | 67 +++++ runtime/syntax/directory.vim | 13 + test/functional/plugin/dir_spec.lua | 328 ++++++++++++++++++++++ 9 files changed, 704 insertions(+), 101 deletions(-) create mode 100644 runtime/lua/nvim/dir.lua create mode 100644 runtime/plugin/dir.lua create mode 100644 runtime/syntax/directory.vim create mode 100644 test/functional/plugin/dir_spec.lua diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 573bcbd7d1..9f65df071e 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -324,6 +324,8 @@ These existing features changed their behavior. • `nvim_exec_autocmds({buf=…})` runs in the context of the target buffer. • |OptionSet| is no longer triggered during startup by automatic |'background'| detection. +• Editing a local directory now shows its contents in a `filetype=directory` + buffer. See |dir|. • |:Open| with no arguments uses the current file. • The "buffer" key was renamed to "buf" in these functions (but the old name "buffer" is still accepted, for backwards compatibility): diff --git a/runtime/doc/plugins.txt b/runtime/doc/plugins.txt index ba96bd2dbc..7b802f0a38 100644 --- a/runtime/doc/plugins.txt +++ b/runtime/doc/plugins.txt @@ -18,6 +18,7 @@ loaded by default while others are not loaded until requested by |:packadd|. Standard plugins *standard-plugin-list* Help-link Loaded Short description ~ +|dir| Yes Directory listing for |:edit| |difftool| No Compares two directories or files side-by-side |editorconfig| Yes Detect and interpret editorconfig |ft-shada| Yes Allows editing binary |shada| files @@ -41,6 +42,29 @@ Help-link Loaded Short description ~ |pi_tutor.txt| Yes Interactive tutorial |pi_zip.txt| Yes Zip archive explorer +============================================================================== +Builtin plugin: dir *dir* + +Nvim opens a directory listing when |:edit| is used with a directory path. The +listing is a buffer with 'filetype' set to "directory". + +Mappings: *dir-mappings* +> + Open the file or directory under the cursor. + - Open the parent directory. + R Reload the directory listing. +< + +These keys map to (nvim-dir-open), (nvim-dir-up), and +(nvim-dir-reload); map those to use different keys. + +The listing is read-only and does not modify the filesystem. + + *g:loaded_nvim_dir_plugin* +To disable this plugin, set this in your config before startup: >lua + vim.g.loaded_nvim_dir_plugin = 1 +< + ============================================================================== Builtin plugin: difftool *difftool* diff --git a/runtime/doc/usr_22.txt b/runtime/doc/usr_22.txt index c09290c8ed..949a563caa 100644 --- a/runtime/doc/usr_22.txt +++ b/runtime/doc/usr_22.txt @@ -24,111 +24,38 @@ Table of contents: |usr_toc.txt| ============================================================================== *22.1* The file browser -Vim has a plugin that makes it possible to edit a directory. Try this: > +Nvim can edit a directory. Try this: > :edit . -Through the magic of autocommands and Vim scripts, the window will be filled -with the contents of the directory. It looks like this (slightly cleaned up -so that it fits within 78 chars): > +The window will be filled with a directory listing. It looks like this: > - " ========================================================================== - " Netrw Directory Listing (netrw v184) - " /path/to/vim/runtime/doc - " Sorted by name - " Sort sequence: [\/]$,*,\(\.bak\|\~\|\.o\|\.h\|\.info\|\.swp\)[*@]\=$ - " Quick Help: :help -:go up dir D:delete R:rename s:sort-by x:special - " ========================================================================== - ../ - ./ check/ - Makefile - autocmd.txt + autoload.txt change.txt eval.txt filetype.txt - help.txt.info + help.txt < -You can see these items: - -1. The name of the browsing tool and its version number -2. The name of the browsing directory -3. The method of sorting (may be by name, time, or size) -4. How names are to be sorted (directories first, then by extension, etc.) -5. How to get help (the key), and an abridged list of available commands -6. A listing of files, including "../" (it will list the parent directory). - -If you have syntax highlighting enabled, the different parts are highlighted -so as to make it easier to spot them. +Directories end in "/". You can use Normal mode Vim commands to move around in the text. For example, move the cursor atop a file and press ; you will then be editing that -file. To go back to the browser use ":edit ." again, or use ":Explore". -CTRL-O also works. +file. To go back to the listing use ":edit ." again. CTRL-O also works. Try using while the cursor is atop a directory name. The result is that the file browser moves into that directory and displays the items found -there. Pressing on the first directory "../" moves you one level -higher. Pressing "-" does the same thing, without the need to move to the -"../" item first. +there. Pressing "-" moves you one level higher, to the parent directory. "R" +reloads the listing. -You can press to get help on the things you can do in the netrw file -browser. This is what you get: -> - QUICK HELP netrw-quickhelp - (Use ctrl-] to select a topic) - Intro to Browsing...............................netrw-intro-browse - Quick Reference: Maps.........................netrw-quickmap - Quick Reference: Commands.....................netrw-browse-cmds -< -The key thus brings you to a netrw directory browsing contents help page. -It's a regular help page; use the usual |CTRL-]| to jump to tagged help items -and |CTRL-O| to jump back. So, if you CTRL-] on |netrw-quickmap| you will -jump to this: -> - netrw-quickmap netrw-quickmaps - QUICK REFERENCE: MAPS netrw-browse-maps +The directory listing opened by ":edit ." is a viewer, not a file manager. For +netrw's browser, file operations, and remote directories, use ":Explore": > - --- ----------------- ---- - Map Quick Explanation Link - --- ----------------- ---- - Causes Netrw to issue help - Netrw will enter the directory or read the file netrw-cr - Netrw will attempt to remove the file/directory netrw-del -< (etc.) + :Explore . +< -To select files for display and editing (with the cursor atop a filename): -> - o Enter the file/directory under the cursor in a new netrw-o - browser window. A horizontal split is used. - O Obtain a file specified by cursor netrw-O - p Preview the file netrw-p - P Browse in the previously used window netrw-P - v Enter the file/directory under the cursor in a new netrw-v - browser window. A vertical split is used. -< -The following normal-mode commands may be used to control the browser display: -> - i Cycle between thin, long, wide, and tree listings netrw-i - r Reverse sorting order netrw-r - s Select sorting style: by name, time, or file size netrw-s -< -As a sampling of extra normal-mode commands: -> - cd Make browsing directory the current directory netrw-cd - D Attempt to remove the file(s)/directory(ies) netrw-D - gb Go to previous bookmarked directory netrw-gb - mb Bookmark current directory netrw-mb - R Rename the designated file(s)/directory(ies) netrw-R -< -One may also use command mode; again, just a sampling: -> - :Explore[!] [dir] Explore directory of current file......netrw-explore - :Hexplore[!] [dir] Horizontal Split & Explore.............netrw-explore -< -The netrw browser is not limited to just your local machine; one may use URLs -such as: > +The netrw browser may also use URLs such as: > :Explore ftp://somehost/path/to/dir/ :e scp://somehost/path/to/dir/ diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt index c73529fdb5..d7c5d2f3f4 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -96,6 +96,8 @@ Defaults *defaults* *nvim-defaults* - 'viminfo' includes "!" - 'wildoptions' defaults to "pum,tagfile" +- |dir| plugin is enabled, so editing a directory shows its contents in a + buffer with 'filetype' set to "directory". - |editorconfig| plugin is enabled, .editorconfig settings are applied. - |man.lua| plugin is enabled, so |:Man| is available by default. - |matchit| plugin is enabled. To disable it in your config: >vim diff --git a/runtime/lua/nvim/dir.lua b/runtime/lua/nvim/dir.lua new file mode 100644 index 0000000000..fc3ca6c73f --- /dev/null +++ b/runtime/lua/nvim/dir.lua @@ -0,0 +1,241 @@ +--- @brief +--- Directory listing for `:edit `. +--- +--- [g:loaded_nvim_dir_plugin]() +--- The plugin can be disabled by setting `g:loaded_nvim_dir_plugin = 1`. + +local api = vim.api +local fs = vim.fs +local uv = vim.uv + +local M = {} + +---@type fun(buf: integer, dir: string) +local first_open + +local navigating = false + +---@param buf integer +---@param options [string, any][] +---@return boolean +local function set_buf_options(buf, options) + for _, option in ipairs(options) do + if not api.nvim_buf_is_valid(buf) then + return false + end + api.nvim_set_option_value(option[1], option[2], { buf = buf }) + end + return api.nvim_buf_is_valid(buf) +end + +---@param path string +---@return string +local function normalize_dir(path) + return fs.normalize(fs.abspath(path)) +end + +---@param name string +---@return string +local function encode_name(name) + return (name:gsub('\n', '\0')) +end + +---@param line string +---@return string +local function decode_line(line) + if line:sub(-1) == '/' then + line = line:sub(1, -2) + end + return (line:gsub('%z', '\n')) +end + +---@param buf integer +---@param dir string +---@return boolean +local function render(buf, dir) + -- TODO(#39878): drop this scandir probe once vim.fs.dir() can report + -- traversal errors. + local handle, err = uv.fs_scandir(dir) + if not handle then + vim.notify('dir: ' .. (err or ('cannot read directory: ' .. dir)), vim.log.levels.ERROR) + return false + end + + ---@type { name: string, dir: boolean }[] + local items = {} + for name, type in fs.dir(dir) do + if type == 'link' and vim.fn.isdirectory(fs.joinpath(dir, name)) == 1 then + type = 'directory' + end + items[#items + 1] = { name = name, dir = type == 'directory' } + end + table.sort(items, function(a, b) + if a.dir ~= b.dir then + return a.dir + end + return a.name < b.name + end) + + local lines = {} ---@type string[] + for i, item in ipairs(items) do + lines[i] = encode_name(item.name) .. (item.dir and '/' or '') + end + + if + not set_buf_options(buf, { + { 'modeline', false }, + { 'buftype', 'nowrite' }, + { 'bufhidden', 'hide' }, + { 'buflisted', true }, + { 'swapfile', false }, + { 'readonly', false }, + { 'modifiable', true }, + }) + then + return false + end + api.nvim_buf_set_name(buf, dir) + if not api.nvim_buf_is_valid(buf) then + return false + end + api.nvim_buf_set_lines(buf, 0, -1, false, lines) + if not api.nvim_buf_is_valid(buf) then + return false + end + return set_buf_options(buf, { + { 'modified', false }, + { 'readonly', true }, + { 'modifiable', false }, + }) +end + +---@param buf integer +---@return string? +local function entry_path(buf) + local lnum = api.nvim_win_get_cursor(0)[1] + local line = api.nvim_buf_get_lines(buf, lnum - 1, lnum, false)[1] + if not line or line == '' then + return nil + end + return fs.joinpath(api.nvim_buf_get_name(buf), decode_line(line)) +end + +---@param path string +local function edit(path) + navigating = true + api.nvim_cmd({ cmd = 'edit', args = { path }, magic = { file = false, bar = false } }, {}) + navigating = false +end + +---@param buf integer +local function reload(buf) + local view = vim.fn.winsaveview() + if render(buf, api.nvim_buf_get_name(buf)) then + vim.fn.winrestview(view) + end +end + +---@param path string +local function navigate(path) + edit(path) + local buf = api.nvim_get_current_buf() + local dir = normalize_dir(api.nvim_buf_get_name(buf)) + if vim.fn.isdirectory(dir) == 0 then + return + end + if vim.b[buf].nvim_dir == nil then + first_open(buf, dir) + else + reload(buf) + end +end + +---@param buf integer +local function open_entry(buf) + local path = entry_path(buf) + if path then + navigate(path) + end +end + +---@param buf integer +local function open_parent(buf) + navigate(fs.dirname(api.nvim_buf_get_name(buf))) +end + +function M._open_entry() + open_entry(api.nvim_get_current_buf()) +end + +function M._open_parent() + open_parent(api.nvim_get_current_buf()) +end + +function M._reload() + reload(api.nvim_get_current_buf()) +end + +---@param buf integer +local function set_maps(buf) + ---@param lhs string + ---@param plug string + local function map(lhs, plug) + if vim.fn.hasmapto(plug, 'n') == 0 then + vim.keymap.set('n', lhs, plug, { buffer = buf, silent = true }) + end + end + map('', '(nvim-dir-open)') + map('-', '(nvim-dir-up)') + map('R', '(nvim-dir-reload)') +end + +---@param buf integer +---@param dir string +function first_open(buf, dir) + if not render(buf, dir) then + return + end + if not api.nvim_buf_is_valid(buf) then + return + end + vim.b[buf].nvim_dir = dir + set_maps(buf) + if api.nvim_get_option_value('filetype', { buf = buf }) ~= 'directory' then + api.nvim_set_option_value('filetype', 'directory', { buf = buf }) + end +end + +---@param buf integer +---@param path string +function M.try_open(buf, path) + if navigating or path == '' then + return + end + if vim.b[buf].nvim_dir ~= nil then + return + end + if vim.bo[buf].buftype ~= '' then + return + end + if vim.bo[buf].filetype == 'netrw' or vim.b[buf].netrw_curdir ~= nil then + return + end + + if vim.fn.isdirectory(path) == 0 then + return + end + first_open(buf, normalize_dir(path)) +end + +function M.handle_startup_dirs() + for _, win in ipairs(api.nvim_tabpage_list_wins(0)) do + if api.nvim_win_is_valid(win) then + api.nvim_win_call(win, function() + local buf = api.nvim_get_current_buf() + M.try_open(buf, api.nvim_buf_get_name(buf)) + end) + end + end +end + +return M diff --git a/runtime/pack/dist/opt/netrw/doc/netrw.txt b/runtime/pack/dist/opt/netrw/doc/netrw.txt index 907a55b721..e7e8bf2756 100644 --- a/runtime/pack/dist/opt/netrw/doc/netrw.txt +++ b/runtime/pack/dist/opt/netrw/doc/netrw.txt @@ -154,10 +154,10 @@ attempting to open, and so one may specify > for each site in a separate file: c:\Users\MyUserName\MachineName. Now about browsing -- when you just want to look around before editing a -file. For browsing on your current host, just "edit" a directory: > +file. For netrw browsing on your current host, use |:Explore|: > - vim . - vim /home/userid/path + :Explore . + :Explore /home/userid/path < For browsing on a remote host, "edit" a directory (but make sure that the directory name is followed by a "/"): > @@ -944,11 +944,11 @@ moving (renaming) files and directories, copying files and directories, etc. One may mark files and execute any system command on them! The Netrw browser generally implements the previous explorer's maps and commands for remote directories, although details (such as pertinent global variable names) -necessarily differ. To browse a directory, simply "edit" it! > +necessarily differ. To browse a local directory with netrw, use |:Explore|: > - vim /your/directory/ - vim . - vim c:\your\directory\ + :Explore /your/directory/ + :Explore . + :Explore c:\your\directory\ < (Related topics: |netrw-cr| |netrw-o| |netrw-p| |netrw-P| |netrw-t| |netrw-mf| |netrw-mx| |netrw-D| |netrw-R| |netrw-v| ) @@ -1344,7 +1344,7 @@ the tree style will become your default listing style. One typical way to use the netrw tree display is to: > - vim . + :Explore . (use i until a tree display shows) navigate to a file v (edit as desired in vertically split window) @@ -1600,9 +1600,9 @@ The optional parameters are: that window. Return from Explorer~ - Conversely, when one is editing a directory, issuing a :Rexplore - will return to editing the file that was last edited in that - window. + Conversely, when one is using a netrw directory browser, issuing a + :Rexplore will return to editing the file that was last edited in + that window. The <2-leftmouse> map (which is only available under gvim and cooperative terms) does the same as :Rexplore. @@ -3029,9 +3029,8 @@ Also see: |g:netrw_chgwin| |netrw-p| REFRESHING THE LISTING *netrw-refresh* *netrw-ctrl-l* *netrw-ctrl_l* {{{2 -To refresh either a local or remote directory listing, press ctrl-l () or -hit the when atop the ./ directory entry in the listing. One may also -refresh a local directory by using ":e .". +To refresh either a local or remote netrw directory listing, press ctrl-l +() or hit the when atop the ./ directory entry in the listing. REVERSING SORTING ORDER *netrw-r* *netrw-reverse* {{{2 @@ -3417,7 +3416,7 @@ Example: Clear netrw's marked file list via a mapping on gu > * Put the following line in your <.vimrc>: let g:netrw_altv = 1 - * Edit the current directory: :e . + * Explore the current directory: :Explore . * Select some file, press v * Resize the windows as you wish (see |CTRL-W_<| and |CTRL-W_>|). If you're using gvim, you can drag diff --git a/runtime/plugin/dir.lua b/runtime/plugin/dir.lua new file mode 100644 index 0000000000..eea0105c47 --- /dev/null +++ b/runtime/plugin/dir.lua @@ -0,0 +1,67 @@ +if vim.g.loaded_nvim_dir_plugin ~= nil then + return +end +vim.g.loaded_nvim_dir_plugin = true + +local api = vim.api +local nvim_on = require('vim._core.util').nvim_on + +vim.keymap.set('n', '(nvim-dir-open)', function() + require('nvim.dir')._open_entry() +end, { silent = true, desc = 'Open directory entry' }) + +vim.keymap.set('n', '(nvim-dir-up)', function() + require('nvim.dir')._open_parent() +end, { silent = true, desc = 'Open parent directory' }) + +vim.keymap.set('n', '(nvim-dir-reload)', function() + require('nvim.dir')._reload() +end, { silent = true, desc = 'Reload directory' }) + +---@param buf integer +---@param path string +---@return boolean +local function should_open(buf, path) + if path == '' then + return false + end + if vim.bo[buf].buftype ~= '' and vim.b[buf].nvim_dir == nil then + return false + end + if vim.bo[buf].filetype == 'netrw' or vim.b[buf].netrw_curdir ~= nil then + return false + end + return vim.fn.isdirectory(path) == 1 +end + +local group = api.nvim_create_augroup('FileExplorer', { clear = true }) +-- Latch on our own VimEnter, not v:vim_did_enter (set just before VimEnter +-- autocmds), so an earlier VimEnter autocmd's BufEnter can't preempt startup. +local vimentered = vim.v.vim_did_enter == 1 + +nvim_on('BufEnter', group, { + pattern = '*', + desc = 'Open local directories', + nested = true, +}, function(ev) + if vimentered and should_open(ev.buf, ev.file) then + require('nvim.dir').try_open(ev.buf, ev.file) + end +end) + +nvim_on('VimEnter', group, { + pattern = '*', + desc = 'Open startup local directories', + nested = true, +}, function() + vimentered = true + for _, win in ipairs(api.nvim_tabpage_list_wins(0)) do + if api.nvim_win_is_valid(win) then + local buf = api.nvim_win_get_buf(win) + if should_open(buf, api.nvim_buf_get_name(buf)) then + require('nvim.dir').handle_startup_dirs() + return + end + end + end +end) diff --git a/runtime/syntax/directory.vim b/runtime/syntax/directory.vim new file mode 100644 index 0000000000..d03bb153f7 --- /dev/null +++ b/runtime/syntax/directory.vim @@ -0,0 +1,13 @@ +" Vim syntax file +" Language: Directory listing +" Maintainer: The Nvim Project + +if exists("b:current_syntax") + finish +endif + +syn match directoryDirectory ".*/$" + +hi def link directoryDirectory Directory + +let b:current_syntax = "directory" diff --git a/test/functional/plugin/dir_spec.lua b/test/functional/plugin/dir_spec.lua new file mode 100644 index 0000000000..6ee9841af9 --- /dev/null +++ b/test/functional/plugin/dir_spec.lua @@ -0,0 +1,328 @@ +local n = require('test.functional.testnvim')() +local t = require('test.testutil') + +local api = n.api +local command = n.command +local eq = t.eq +local exec_capture = n.exec_capture +local exec_lua = n.exec_lua +local feed = n.feed +local fn = n.fn +local ok = t.ok +local poke_eventloop = n.poke_eventloop + +local function lines() + return api.nvim_buf_get_lines(0, 0, -1, true) +end + +local function edit(path) + api.nvim_cmd({ cmd = 'edit', args = { path }, magic = { file = false, bar = false } }, {}) +end + +local function cd(path) + api.nvim_cmd({ cmd = 'cd', args = { path }, magic = { file = false, bar = false } }, {}) +end + +local function line_of(text) + for i, line in ipairs(lines()) do + if line == text then + return i + end + end + error(('missing line %q in %s'):format(text, vim.inspect(lines()))) +end + +local function bufopt(name) + return api.nvim_get_option_value(name, { buf = 0 }) +end + +local function assert_directory(path) + eq(path, api.nvim_buf_get_name(0)) + eq(path, fn.bufname('%')) + eq('directory', bufopt('filetype')) + eq(true, bufopt('buflisted')) +end + +local function filesystem_root(path) + local root = vim.fs.normalize(vim.fs.abspath(path)) + for parent in vim.fs.parents(root) do + root = parent + end + return root +end + +---@param args? string[] +---@return string[] +local function with_buftype_optionset(args) + return vim.list_extend({ + '--cmd', + 'let g:nvim_dir_events = []', + '--cmd', + [[autocmd OptionSet buftype call add(g:nvim_dir_events, v:option_new)]], + }, args or {}) +end + +local function expect_buftype_optionset(path) + assert_directory(path) + eq({ 'nowrite' }, exec_lua('return vim.g.nvim_dir_events')) +end + +describe('nvim.dir', function() + local root + local subdir + local file + + local function make_fixture() + root = vim.fs.normalize(t.tmpname(false) .. ' space%#') + subdir = root .. '/subdir' + file = root .. '/alpha.txt' + t.mkdir(root) + t.mkdir(subdir) + t.write_file(file, 'alpha', true) + t.write_file(root .. '/.hidden', 'hidden', true) + end + + after_each(function() + if root then + n.rmdir(root) + end + root = nil + end) + + it('opens a startup directory argument', function() + make_fixture() + n.clear({ args_rm = { '-u' }, args = { root } }) + + assert_directory(root) + eq(false, vim.tbl_contains(lines(), '../')) + eq('subdir/', lines()[1]) + line_of('.hidden') + line_of('alpha.txt') + end) + + it('triggers nested autocmds when opening directory buffers', function() + make_fixture() + + n.clear({ + args_rm = { '-u' }, + args = with_buftype_optionset({ root }), + }) + expect_buftype_optionset(root) + + n.clear({ + args_rm = { '-u' }, + args = with_buftype_optionset(), + }) + edit(root) + expect_buftype_optionset(root) + end) + + it('handles nested autocmds deleting the directory buffer', function() + make_fixture() + n.clear({ + args_rm = { '-u' }, + args = { + '--cmd', + 'let g:nvim_dir_wiped = 0', + '--cmd', + [[autocmd OptionSet buftype let g:nvim_dir_wiped = 1 | bwipeout!]], + root, + }, + }) + + eq(1, exec_lua('return vim.g.nvim_dir_wiped')) + eq('', exec_capture('messages')) + end) + + it('does not load the module until opening a directory', function() + make_fixture() + n.clear({ args_rm = { '-u' } }) + + eq(false, exec_lua([[return package.loaded['nvim.dir'] ~= nil]])) + edit(root) + eq(true, exec_lua([[return package.loaded['nvim.dir'] ~= nil]])) + assert_directory(root) + end) + + it('uses an absolute buffer name for a relative startup directory argument', function() + if t.is_zig_build() then + return pending('broken with build.zig relative runtime paths after chdir') + end + make_fixture() + local cwd = assert(vim.uv.cwd()) + assert(vim.uv.chdir(root)) + n.clear({ args_rm = { '-u' }, args = { '.' } }) + assert(vim.uv.chdir(cwd)) + + assert_directory(root) + end) + + it('normalizes edited directory names', function() + make_fixture() + n.clear({ args_rm = { '-u' } }) + + edit(root .. '///') + + assert_directory(root) + end) + + it('does not show a parent entry at the filesystem root', function() + n.clear({ args_rm = { '-u' } }) + local root_dir = filesystem_root(fn.getcwd()) + + edit(root_dir) + + assert_directory(root_dir) + eq(false, vim.tbl_contains(lines(), '../')) + end) + + it('navigates entries and refreshes the listing', function() + make_fixture() + n.clear({ args_rm = { '-u' } }) + + edit(root) + assert_directory(root) + + api.nvim_win_set_cursor(0, { line_of('alpha.txt'), 0 }) + feed('') + poke_eventloop() + eq(file, api.nvim_buf_get_name(0)) + eq({ 'alpha' }, lines()) + + edit(subdir) + assert_directory(subdir) + eq({ '' }, lines()) + + feed('-') + poke_eventloop() + assert_directory(root) + + t.write_file(root .. '/beta.txt', 'beta', true) + feed('R') + poke_eventloop() + line_of('beta.txt') + end) + + it('reports an error and keeps the buffer when reloading a removed directory', function() + make_fixture() + n.clear({ args_rm = { '-u' } }) + + edit(subdir) + assert_directory(subdir) + + n.rmdir(subdir) + feed('R') + poke_eventloop() + + ok(exec_capture('messages'):find('ENOENT', 1, true) ~= nil) + assert_directory(subdir) + end) + + it('refreshes a directory when navigated into again', function() + make_fixture() + n.clear({ args_rm = { '-u' } }) + + edit(root) + api.nvim_win_set_cursor(0, { line_of('subdir/'), 0 }) + feed('') + poke_eventloop() + assert_directory(subdir) + eq({ '' }, lines()) + + t.write_file(subdir .. '/new.txt', 'new', true) + feed('-') + poke_eventloop() + assert_directory(root) + + api.nvim_win_set_cursor(0, { line_of('subdir/'), 0 }) + feed('') + poke_eventloop() + assert_directory(subdir) + line_of('new.txt') + end) + + it('encodes special filename characters in directory buffers', function() + -- Windows reserves backslash as a separator and disallows control characters in filenames. + -- https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file + t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') + make_fixture() + n.clear({ args_rm = { '-u' } }) + + local name = 'line\nbreak.txt' + local raw_names = { + 'back\\slash.txt', + 'tab\tname.txt', + 'carriage\rreturn.txt', + 'ctrl\1name.txt', + 'del\127name.txt', + } + for _, raw_name in ipairs(raw_names) do + t.write_file(root .. '/' .. raw_name, 'raw', true) + end + t.write_file(root .. '/' .. name, 'newline', true) + + edit(root) + for _, raw_name in ipairs(raw_names) do + line_of(raw_name) + end + api.nvim_win_set_cursor(0, { line_of('line\0break.txt'), 0 }) + feed('') + poke_eventloop() + + eq(root .. '/' .. name, api.nvim_buf_get_name(0)) + eq({ 'newline' }, lines()) + end) + + it('leaves existing special buffers alone', function() + make_fixture() + n.clear({ args_rm = { '-u' } }) + + api.nvim_set_option_value('buftype', 'nofile', { buf = 0 }) + api.nvim_buf_set_name(0, root) + command('doautocmd BufEnter') + + eq('nofile', api.nvim_get_option_value('buftype', { buf = 0 })) + eq('', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('coexists with netrw and can be disabled', function() + if t.is_zig_build() then + return pending('broken with build.zig relative runtime paths after chdir') + end + make_fixture() + n.clear({ args_rm = { '-u' } }) + local cwd = fn.getcwd() + + ok(fn.exists(':Explore') > 0) + edit(root) + eq('directory', api.nvim_get_option_value('filetype', { buf = 0 })) + + cd(root) + command('Explore .') + cd(cwd) + eq('netrw', api.nvim_get_option_value('filetype', { buf = 0 })) + + n.clear({ + args_rm = { '-u' }, + args = { '--cmd', 'let g:loaded_nvim_dir_plugin = 1' }, + }) + edit(root) + eq('netrw', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('supports the FileExplorer browse contract', function() + if t.is_zig_build() then + return pending('broken with build.zig relative runtime paths after chdir') + end + make_fixture() + n.clear({ args_rm = { '-u' } }) + local cwd = fn.getcwd() + + cd(root) + command('browse edit .') + cd(cwd) + + assert_directory(root) + line_of('alpha.txt') + end) +end)