From ada20074535c253fe31ef5c6a927f9c38367fa8f Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 22 Jul 2026 10:21:39 -0700
Subject: [PATCH] feat(dir): provider framework #40738
Problem:
The core behaviors of `nvim.dir` cannot be reused by consumers
such as the archive plugin, ssh browser, etc.
Solution:
- Define a basic "provider" shape.
- Extract the filesystem provider.
---
runtime/lua/nvim/dir.lua | 325 ++++++++++++++++++----------
runtime/lua/nvim/dir/fs.lua | 101 +++++++++
test/functional/plugin/dir_spec.lua | 158 ++++++++++++++
3 files changed, 473 insertions(+), 111 deletions(-)
create mode 100644 runtime/lua/nvim/dir/fs.lua
diff --git a/runtime/lua/nvim/dir.lua b/runtime/lua/nvim/dir.lua
index daf5bd7a09..f8b0b6b2d3 100644
--- a/runtime/lua/nvim/dir.lua
+++ b/runtime/lua/nvim/dir.lua
@@ -2,14 +2,38 @@
--- Directory listing for `:edit
`.
local api = vim.api
-local fs = vim.fs
local M = {}
----@type fun(buf: integer, dir: string)
-local first_open
+--- An entry rendered as one line in a listing buffer.
+---@class (private) nvim.dir.Entry
+---@field name string
+---@field dir boolean
-local navigating = false
+--- Handler for `Provider.list`; call once with either an error or entries.
+---@alias (private) nvim.dir.ListHandler fun(err: string?, entries: nvim.dir.Entry[]?)
+
+--- Source adapter that provides entries and listing actions.
+---@class (private) nvim.dir.Provider
+--- Produce entries for this listing.
+---@field list fun(buf: integer, name: string, cb: nvim.dir.ListHandler)
+--- Open an entry from the listing.
+---@field open fun(buf: integer, name: string, entry: nvim.dir.Entry)
+--- Open the parent listing.
+---@field open_parent fun(buf: integer, name: string)
+--- Run provider-specific buffer setup after a successful open.
+---@field init? fun(buf: integer, name: string)
+
+---@class (private) nvim.dir.State
+---@field gen integer
+---@field provider? nvim.dir.Provider
+
+local listing_group = api.nvim_create_augroup('nvim.dir.listing', { clear = false })
+
+---@param err any
+local function notify_error(err)
+ vim.notify('dir: ' .. tostring(err), vim.log.levels.ERROR)
+end
---@param buf integer
---@param options [string, any][]
@@ -24,53 +48,32 @@ local function set_buf_options(buf, options)
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
+---@param entry nvim.dir.Entry
---@return string
-local function decode_line(line)
- if line:sub(-1) == '/' then
- line = line:sub(1, -2)
- end
- return (line:gsub('%z', '\n'))
+local function entry_line(entry)
+ return encode_name(entry.name) .. (entry.dir and '/' or '')
+end
+
+---@param entry nvim.dir.Entry
+local function select_entry(entry)
+ local line = entry_line(entry)
+ vim.fn.search([[\C\m^\V]] .. vim.fn.escape(line, [[\]]) .. [[\m$]], 'cw')
end
---@param buf integer
----@param dir string
+---@param name string
+---@param entries nvim.dir.Entry[]
---@return boolean
-local function render(buf, dir)
- ---@type { name: string, dir: boolean }[]
- local items = {}
- for name, type, err in fs.dir(dir, { err = true }) do
- if err then
- vim.notify('dir: ' .. err, vim.log.levels.ERROR)
- return false
- end
- 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 function render_entries(buf, name, entries)
local lines = {} ---@type string[]
- for i, item in ipairs(items) do
- lines[i] = encode_name(item.name) .. (item.dir and '/' or '')
+ for i, entry in ipairs(entries) do
+ lines[i] = entry_line(entry)
end
if
@@ -88,7 +91,7 @@ local function render(buf, dir)
api.nvim_buf_call(buf, function()
api.nvim_cmd({
cmd = 'file',
- args = { dir },
+ args = { name },
mods = { keepalt = true },
magic = { file = false, bar = false },
}, {})
@@ -107,73 +110,59 @@ local function render(buf, dir)
})
end
+--- Stop tracking a listing and remove its autocmds.
---@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
+local function close_listing(buf)
+ if not api.nvim_buf_is_valid(buf) then
return
end
- if vim.b[buf].nvim_dir == nil then
- first_open(buf, dir)
- else
- reload(buf)
- end
+ vim.b[buf].nvim_dir = nil
+ api.nvim_clear_autocmds({ group = listing_group, buffer = buf })
end
---@param buf integer
-local function open_entry(buf)
- local path = entry_path(buf)
- if path then
- navigate(path)
+---@return nvim.dir.State?
+local function get_state(buf)
+ if not api.nvim_buf_is_valid(buf) then
+ return nil
end
+ local state = vim.b[buf].nvim_dir
+ return type(state) == 'table' and state or nil
end
---@param buf integer
-local function open_parent(buf)
- local path = fs.normalize(api.nvim_buf_get_name(buf))
- local name = encode_name(fs.basename(path)) .. (vim.fn.isdirectory(path) == 1 and '/' or '')
- navigate(fs.dirname(path))
- vim.fn.search([[\C\m^\V]] .. vim.fn.escape(name, [[\]]) .. [[\m$]], 'cw')
+---@return nvim.dir.Entry?
+local function current_entry(buf)
+ local state = get_state(buf)
+ if not state or not state.provider or api.nvim_get_current_buf() ~= buf then
+ return nil
+ end
+ local line = api.nvim_get_current_line()
+ if line == '' then
+ return nil
+ end
+ local dir = line:sub(-1) == '/'
+ if dir then
+ line = line:sub(1, -2)
+ end
+ if line == '' then
+ return nil
+ end
+ return { name = line:gsub('%z', '\n'), dir = dir }
end
-function M._open_entry()
- open_entry(api.nvim_get_current_buf())
-end
+---@type fun(buf: integer, name: string, provider: nvim.dir.Provider, restore_view?: table, setup?: boolean, select?: nvim.dir.Entry)
+local load
-function M._open_parent()
- open_parent(api.nvim_get_current_buf())
-end
-
-function M._reload()
- reload(api.nvim_get_current_buf())
+---@param buf integer
+---@param provider nvim.dir.Provider
+local function reload(buf, provider)
+ local state = get_state(buf)
+ if not state or not state.provider then
+ return
+ end
+ local restore_view = api.nvim_get_current_buf() == buf and vim.fn.winsaveview() or nil
+ load(buf, api.nvim_buf_get_name(buf), provider, restore_view)
end
---@param buf integer
@@ -198,35 +187,149 @@ local function set_maps(buf)
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
+local function setup_render_autocmds(buf)
+ api.nvim_clear_autocmds({ group = listing_group, buffer = buf })
api.nvim_create_autocmd('BufReadCmd', {
+ group = listing_group,
buffer = buf,
nested = true,
desc = 'Reload directory listing',
callback = function()
- if vim.b[buf].nvim_dir ~= nil then
- reload(buf)
- end
+ M._reload(buf)
end,
})
- set_maps(buf)
- if api.nvim_get_option_value('filetype', { buf = buf }) ~= 'directory' then
- api.nvim_set_option_value('filetype', 'directory', { buf = buf })
+end
+
+--- Request entries and render the current list generation.
+---@param buf integer
+---@param name string
+---@param provider nvim.dir.Provider
+---@param restore_view? table
+---@param setup? boolean
+---@param select? nvim.dir.Entry
+function load(buf, name, provider, restore_view, setup, select)
+ local state = get_state(buf) or { gen = 0 }
+ local list_gen = state.gen + 1
+ state.gen = list_gen
+ vim.b[buf].nvim_dir = state
+ -- Discard the listing state if the initial load fails, but preserve an existing listing on reload failure.
+ local first_render = state.provider == nil
+ local done = false
+
+ ---@param err string?
+ ---@param entries nvim.dir.Entry[]?
+ local function on_list(err, entries)
+ if done then
+ return
+ end
+ done = true
+ local current_state = get_state(buf)
+ if not current_state or current_state.gen ~= list_gen then
+ return
+ end
+ if err ~= nil then
+ notify_error(err)
+ if first_render then
+ close_listing(buf)
+ end
+ return
+ end
+ ---@cast entries nvim.dir.Entry[]
+
+ if not render_entries(buf, name, entries) then
+ if first_render then
+ close_listing(buf)
+ end
+ return
+ end
+ if restore_view and api.nvim_get_current_buf() == buf then
+ vim.fn.winrestview(restore_view)
+ end
+ if select and api.nvim_get_current_buf() == buf then
+ select_entry(select)
+ end
+ current_state.provider = provider
+ vim.b[buf].nvim_dir = current_state
+
+ if not setup then
+ return
+ end
+
+ setup_render_autocmds(buf)
+ set_maps(buf)
+ if provider.init then
+ provider.init(buf, name)
+ end
+ end
+
+ local ok, call_err = pcall(provider.list, buf, name, on_list) ---@type boolean, any
+ if not ok then
+ -- Route provider exceptions through the list handler so failures share one cleanup path.
+ on_list(tostring(call_err), nil)
+ end
+end
+
+--- Open {buf} as a listing named {name} using {provider}.
+---@param buf integer
+---@param name string
+---@param provider nvim.dir.Provider
+---@param select? nvim.dir.Entry
+function M.open(buf, name, provider, select)
+ buf = vim._resolve_bufnr(buf)
+ local state = get_state(buf)
+ local restore_view = state
+ and state.provider
+ and api.nvim_get_current_buf() == buf
+ and vim.fn.winsaveview()
+ or nil
+ load(buf, name, provider, restore_view, true, select)
+end
+
+---@param buf integer
+---@return nvim.dir.Provider?
+local function get_provider(buf)
+ local state = get_state(buf)
+ return state and state.provider or nil
+end
+
+function M._open_entry()
+ local buf = api.nvim_get_current_buf()
+ local provider = get_provider(buf)
+ local entry = current_entry(buf)
+ if provider and entry then
+ provider.open(buf, api.nvim_buf_get_name(buf), entry)
+ end
+end
+
+function M._open_parent()
+ local buf = api.nvim_get_current_buf()
+ local provider = get_provider(buf)
+ if provider then
+ provider.open_parent(buf, api.nvim_buf_get_name(buf))
+ return
+ end
+ -- Keep the global `-` mapping useful from regular file buffers.
+ require('nvim.dir.fs').open_parent_path(api.nvim_buf_get_name(buf))
+end
+
+--- Reload the existing listing with its current buffer name and provider.
+--- Replace the lines and restore the view only after a successful list; preserve the existing
+--- listing on failure. Do not rerun provider initialization or reinstall listing handlers.
+---@param buf? integer
+function M._reload(buf)
+ buf = vim._resolve_bufnr(buf)
+ local provider = get_provider(buf)
+ if provider then
+ reload(buf, provider)
end
end
---@param buf integer
---@param path string
function M.try_open(buf, path)
- if navigating or path == '' then
+ buf = vim._resolve_bufnr(buf)
+ local fs_provider = require('nvim.dir.fs')
+ if fs_provider.is_navigating() or path == '' then
return
end
if vim.b[buf].nvim_dir ~= nil then
@@ -242,7 +345,7 @@ function M.try_open(buf, path)
if vim.fn.isdirectory(path) == 0 then
return
end
- first_open(buf, normalize_dir(path))
+ M.open(buf, fs_provider.normalize(path), fs_provider)
end
function M.handle_startup_dirs()
diff --git a/runtime/lua/nvim/dir/fs.lua b/runtime/lua/nvim/dir/fs.lua
new file mode 100644
index 0000000000..33aa0b6b60
--- /dev/null
+++ b/runtime/lua/nvim/dir/fs.lua
@@ -0,0 +1,101 @@
+-- Filesystem backend.
+
+local api = vim.api
+local fs = vim.fs
+
+local M = {}
+
+local navigating = false
+
+---@param path string
+---@return string
+function M.normalize(path)
+ return fs.normalize(fs.abspath(path))
+end
+
+---@return boolean
+function M.is_navigating()
+ return navigating
+end
+
+---@param path string
+local function edit(path)
+ navigating = true
+ local ok, err = pcall(api.nvim_cmd, {
+ cmd = 'edit',
+ args = { path },
+ magic = { file = false, bar = false },
+ })
+ navigating = false
+ if not ok then
+ error(err, 0)
+ end
+end
+
+---@param path string
+---@param select? nvim.dir.Entry
+local function navigate(path, select)
+ edit(path)
+ local buf = api.nvim_get_current_buf()
+ local dir = M.normalize(api.nvim_buf_get_name(buf))
+ if vim.fn.isdirectory(dir) == 0 then
+ return
+ end
+
+ require('nvim.dir').open(buf, dir, M, select)
+end
+
+---@param path string
+function M.open_parent_path(path)
+ path = M.normalize(path)
+ navigate(fs.dirname(path), { name = fs.basename(path), dir = vim.fn.isdirectory(path) == 1 })
+end
+
+---@param _ integer
+---@param path string
+---@param cb fun(err?: string, entries?: nvim.dir.Entry[])
+function M.list(_, path, cb)
+ local entries = {} ---@type nvim.dir.Entry[]
+ for name, type, err in fs.dir(path, { err = true }) do
+ if err then
+ cb(err)
+ return
+ end
+ if type == 'link' and vim.fn.isdirectory(fs.joinpath(path, name)) == 1 then
+ type = 'directory'
+ end
+ entries[#entries + 1] = {
+ name = name,
+ dir = type == 'directory',
+ }
+ end
+ table.sort(entries, function(a, b)
+ if a.dir ~= b.dir then
+ return a.dir
+ end
+ return a.name < b.name
+ end)
+ cb(nil, entries)
+end
+
+---@param _ integer
+---@param path string
+---@param entry nvim.dir.Entry
+function M.open(_, path, entry)
+ navigate(fs.joinpath(path, entry.name))
+end
+
+---@param _ integer
+---@param path string
+function M.open_parent(_, path)
+ M.open_parent_path(path)
+end
+
+---@param buf integer
+function M.init(buf)
+ if api.nvim_get_option_value('filetype', { buf = buf }) ~= 'directory' then
+ api.nvim_set_option_value('filetype', 'directory', { buf = buf })
+ end
+end
+
+return M
diff --git a/test/functional/plugin/dir_spec.lua b/test/functional/plugin/dir_spec.lua
index 5cc9e7fef8..339ebefda0 100644
--- a/test/functional/plugin/dir_spec.lua
+++ b/test/functional/plugin/dir_spec.lua
@@ -190,6 +190,164 @@ describe('nvim.dir', function()
assert_directory(root)
end)
+ it('opens a custom listing provider', function()
+ n.clear({ args_rm = { '-u' } })
+
+ exec_lua(function()
+ require('nvim.dir').open(0, 'custom://root', {
+ list = function(_, name, cb)
+ vim.g.nvim_dir_list_name = name
+ cb(nil, {
+ { name = 'child', dir = true },
+ { name = 'file.txt', dir = false },
+ })
+ end,
+ open = function(_, name, entry)
+ vim.g.nvim_dir_opened = entry.name .. ':' .. name
+ end,
+ open_parent = function(_, name)
+ vim.g.nvim_dir_parent = name
+ end,
+ init = function(buf)
+ vim.bo[buf].filetype = 'customdir'
+ end,
+ })
+ end)
+
+ eq('custom://root', api.nvim_buf_get_name(0))
+ eq('customdir', bufopt('filetype'))
+ eq({ 'child/', 'file.txt' }, lines())
+ eq('custom://root', exec_lua('return vim.g.nvim_dir_list_name'))
+
+ api.nvim_win_set_cursor(0, { 2, 0 })
+ api.nvim_set_option_value('modifiable', true, { buf = 0 })
+ api.nvim_set_current_line('renamed.txt')
+ feed('')
+ poke_eventloop()
+ eq('renamed.txt:custom://root', exec_lua('return vim.g.nvim_dir_opened'))
+
+ feed('-')
+ poke_eventloop()
+ eq('custom://root', exec_lua('return vim.g.nvim_dir_parent'))
+ end)
+
+ it('reports custom listing provider errors', function()
+ n.clear({ args_rm = { '-u' } })
+
+ exec_lua(function()
+ require('nvim.dir').open(0, 'custom://error', {
+ list = function(_, _, cb)
+ cb('simulated error')
+ end,
+ open = function() end,
+ open_parent = function() end,
+ })
+ end)
+
+ ok(exec_capture('messages'):find('simulated error', 1, true) ~= nil)
+ eq(false, exec_lua([[return vim.b.nvim_dir ~= nil]]))
+ end)
+
+ it('reloads custom listing providers', function()
+ n.clear({ args_rm = { '-u' } })
+
+ exec_lua(function()
+ require('nvim.dir').open(0, 'custom://root', {
+ list = function(buf, _, cb)
+ vim.b[buf].custom_count = (vim.b[buf].custom_count or 0) + 1
+ cb(nil, { { name = 'file' .. vim.b[buf].custom_count .. '.txt', dir = false } })
+ end,
+ open = function() end,
+ open_parent = function() end,
+ })
+ end)
+
+ eq({ 'file1.txt' }, lines())
+ feed('R')
+ poke_eventloop()
+ eq({ 'file2.txt' }, lines())
+ end)
+
+ it('ignores callbacks from replaced listings', function()
+ n.clear({ args_rm = { '-u' } })
+
+ exec_lua(function()
+ local dir = require('nvim.dir')
+ dir.open(0, 'custom://old', {
+ list = function(_, _, cb)
+ _G.nvim_dir_stale_callback = cb
+ end,
+ open = function() end,
+ open_parent = function() end,
+ })
+ dir.open(0, 'custom://new', {
+ list = function(_, _, cb)
+ cb(nil, { { name = 'current.txt', dir = false } })
+ end,
+ open = function() end,
+ open_parent = function() end,
+ })
+ end)
+
+ eq('custom://new', api.nvim_buf_get_name(0))
+ eq({ 'current.txt' }, lines())
+
+ exec_lua(function()
+ _G.nvim_dir_stale_callback(nil, { { name = 'stale.txt', dir = false } })
+ end)
+
+ eq('custom://new', api.nvim_buf_get_name(0))
+ eq({ 'current.txt' }, lines())
+ end)
+
+ it('replaces listing providers', function()
+ n.clear({ args_rm = { '-u' } })
+
+ exec_lua(function()
+ local dir = require('nvim.dir')
+ local function provider(label)
+ return {
+ list = function(buf, _, cb)
+ local key = 'nvim_dir_' .. label .. '_lists'
+ vim.b[buf][key] = (vim.b[buf][key] or 0) + 1
+ vim.g.nvim_dir_provider_list = label .. ':' .. vim.b[buf][key]
+ cb(nil, { { name = label .. '.txt', dir = false } })
+ end,
+ open = function(_, _, entry)
+ vim.g.nvim_dir_provider_open = label .. ':' .. entry.name
+ end,
+ open_parent = function()
+ vim.g.nvim_dir_provider_parent = label
+ end,
+ }
+ end
+ dir.open(0, 'custom://old', provider('old'))
+ dir.open(0, 'custom://new', provider('new'))
+ end)
+
+ eq({ 'new.txt' }, lines())
+ eq('new:1', exec_lua('return vim.g.nvim_dir_provider_list'))
+ for _, plug in ipairs({
+ '(nvim-dir-open)',
+ '(nvim-dir-up)',
+ '(nvim-dir-reload)',
+ }) do
+ eq(0, fn.maparg(plug, 'n', false, true).buffer)
+ end
+
+ feed('')
+ poke_eventloop()
+ eq('new:new.txt', exec_lua('return vim.g.nvim_dir_provider_open'))
+
+ feed('-')
+ poke_eventloop()
+ eq('new', exec_lua('return vim.g.nvim_dir_provider_parent'))
+
+ feed('R')
+ poke_eventloop()
+ eq('new:2', exec_lua('return vim.g.nvim_dir_provider_list'))
+ end)
+
it('maps - to open parent directories', function()
make_fixture()
n.clear({ args_rm = { '-u', '--cmd' } })