Problem: files were always created with mode 0644 and directories with 0755, hardcoded in fs.touch and uv.fs_mkdir. Users who need different defaults (e.g. 0600 for security) had no config option. Solution: add new_file_mode (default 420 = 0644) and new_dir_mode (default 493 = 0755) config options, passed through to fs.touch and uv.fs_mkdir in the files and mac trash adapters. The fs.touch signature accepts an optional mode parameter with backwards compatibility (detects function argument to support old callers). Local cache directories (SSH, S3) continue using standard system permissions rather than the user-configured mode. Based on: stevearc/oil.nvim#537
663 lines
19 KiB
Lua
663 lines
19 KiB
Lua
local cache = require("oil.cache")
|
|
local columns = require("oil.columns")
|
|
local config = require("oil.config")
|
|
local constants = require("oil.constants")
|
|
local fs = require("oil.fs")
|
|
local git = require("oil.git")
|
|
local log = require("oil.log")
|
|
local permissions = require("oil.adapters.files.permissions")
|
|
local util = require("oil.util")
|
|
local uv = vim.uv or vim.loop
|
|
|
|
local M = {}
|
|
|
|
local FIELD_NAME = constants.FIELD_NAME
|
|
local FIELD_TYPE = constants.FIELD_TYPE
|
|
local FIELD_META = constants.FIELD_META
|
|
|
|
local function read_link_data(path, cb)
|
|
uv.fs_readlink(
|
|
path,
|
|
vim.schedule_wrap(function(link_err, link)
|
|
if link_err then
|
|
cb(link_err)
|
|
else
|
|
assert(link)
|
|
local stat_path = link
|
|
if not fs.is_absolute(link) then
|
|
stat_path = fs.join(vim.fn.fnamemodify(path, ":h"), link)
|
|
end
|
|
uv.fs_stat(stat_path, function(stat_err, stat)
|
|
cb(nil, link, stat)
|
|
end)
|
|
end
|
|
end)
|
|
)
|
|
end
|
|
|
|
---@class (exact) oil.FilesAdapter: oil.Adapter
|
|
---@field to_short_os_path fun(path: string, entry_type: nil|oil.EntryType): string
|
|
|
|
---@param path string
|
|
---@param entry_type nil|oil.EntryType
|
|
---@return string
|
|
M.to_short_os_path = function(path, entry_type)
|
|
local shortpath = fs.shorten_path(fs.posix_to_os_path(path))
|
|
if entry_type == "directory" then
|
|
shortpath = util.addslash(shortpath, true)
|
|
end
|
|
return shortpath
|
|
end
|
|
|
|
local file_columns = {}
|
|
|
|
file_columns.size = {
|
|
require_stat = true,
|
|
|
|
render = function(entry, conf)
|
|
local meta = entry[FIELD_META]
|
|
local stat = meta and meta.stat
|
|
if not stat then
|
|
return columns.EMPTY
|
|
end
|
|
if stat.size >= 1e9 then
|
|
return string.format("%.1fG", stat.size / 1e9)
|
|
elseif stat.size >= 1e6 then
|
|
return string.format("%.1fM", stat.size / 1e6)
|
|
elseif stat.size >= 1e3 then
|
|
return string.format("%.1fk", stat.size / 1e3)
|
|
else
|
|
return string.format("%d", stat.size)
|
|
end
|
|
end,
|
|
|
|
get_sort_value = function(entry)
|
|
local meta = entry[FIELD_META]
|
|
local stat = meta and meta.stat
|
|
if stat then
|
|
return stat.size
|
|
else
|
|
return 0
|
|
end
|
|
end,
|
|
|
|
parse = function(line, conf)
|
|
return line:match("^(%d+%S*)%s+(.*)$")
|
|
end,
|
|
}
|
|
|
|
-- TODO support file permissions on windows
|
|
if not fs.is_windows then
|
|
file_columns.permissions = {
|
|
require_stat = true,
|
|
|
|
render = function(entry, conf)
|
|
local meta = entry[FIELD_META]
|
|
local stat = meta and meta.stat
|
|
if not stat then
|
|
return columns.EMPTY
|
|
end
|
|
return permissions.mode_to_str(stat.mode)
|
|
end,
|
|
|
|
parse = function(line, conf)
|
|
return permissions.parse(line)
|
|
end,
|
|
|
|
compare = function(entry, parsed_value)
|
|
local meta = entry[FIELD_META]
|
|
if parsed_value and meta and meta.stat and meta.stat.mode then
|
|
local mask = bit.lshift(1, 12) - 1
|
|
local old_mode = bit.band(meta.stat.mode, mask)
|
|
if parsed_value ~= old_mode then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end,
|
|
|
|
render_action = function(action)
|
|
local _, path = util.parse_url(action.url)
|
|
assert(path)
|
|
return string.format(
|
|
"CHMOD %s %s",
|
|
permissions.mode_to_octal_str(action.value),
|
|
M.to_short_os_path(path, action.entry_type)
|
|
)
|
|
end,
|
|
|
|
perform_action = function(action, callback)
|
|
local _, path = util.parse_url(action.url)
|
|
assert(path)
|
|
path = fs.posix_to_os_path(path)
|
|
uv.fs_stat(path, function(err, stat)
|
|
if err then
|
|
return callback(err)
|
|
end
|
|
assert(stat)
|
|
-- We are only changing the lower 12 bits of the mode
|
|
local mask = bit.bnot(bit.lshift(1, 12) - 1)
|
|
local old_mode = bit.band(stat.mode, mask)
|
|
uv.fs_chmod(path, bit.bor(old_mode, action.value), callback)
|
|
end)
|
|
end,
|
|
}
|
|
end
|
|
|
|
local current_year
|
|
-- Make sure we run this import-time effect in the main loop (mostly for tests)
|
|
vim.schedule(function()
|
|
current_year = vim.fn.strftime("%Y")
|
|
end)
|
|
|
|
for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do
|
|
file_columns[time_key] = {
|
|
require_stat = true,
|
|
|
|
render = function(entry, conf)
|
|
local meta = entry[FIELD_META]
|
|
local stat = meta and meta.stat
|
|
if not stat then
|
|
return columns.EMPTY
|
|
end
|
|
local fmt = conf and conf.format
|
|
local ret
|
|
if fmt then
|
|
ret = vim.fn.strftime(fmt, stat[time_key].sec)
|
|
else
|
|
local year = vim.fn.strftime("%Y", stat[time_key].sec)
|
|
if year ~= current_year then
|
|
ret = vim.fn.strftime("%b %d %Y", stat[time_key].sec)
|
|
else
|
|
ret = vim.fn.strftime("%b %d %H:%M", stat[time_key].sec)
|
|
end
|
|
end
|
|
return ret
|
|
end,
|
|
|
|
parse = function(line, conf)
|
|
local fmt = conf and conf.format
|
|
local pattern
|
|
if fmt then
|
|
-- Replace placeholders with a pattern that matches non-space characters (e.g. %H -> %S+)
|
|
-- and whitespace with a pattern that matches any amount of whitespace
|
|
-- e.g. "%b %d %Y" -> "%S+%s+%S+%s+%S+"
|
|
pattern = fmt
|
|
:gsub("%%.", "%%S+")
|
|
:gsub("%s+", "%%s+")
|
|
-- escape `()[]` because those are special characters in Lua patterns
|
|
:gsub(
|
|
"%(",
|
|
"%%("
|
|
)
|
|
:gsub("%)", "%%)")
|
|
:gsub("%[", "%%[")
|
|
:gsub("%]", "%%]")
|
|
else
|
|
pattern = "%S+%s+%d+%s+%d%d:?%d%d"
|
|
end
|
|
return line:match("^(" .. pattern .. ")%s+(.+)$")
|
|
end,
|
|
|
|
get_sort_value = function(entry)
|
|
local meta = entry[FIELD_META]
|
|
local stat = meta and meta.stat
|
|
if stat then
|
|
return stat[time_key].sec
|
|
else
|
|
return 0
|
|
end
|
|
end,
|
|
}
|
|
end
|
|
|
|
---@param column_defs table[]
|
|
---@return boolean
|
|
local function columns_require_stat(column_defs)
|
|
for _, def in ipairs(column_defs) do
|
|
local name = util.split_config(def)
|
|
local column = M.get_column(name)
|
|
---@diagnostic disable-next-line: undefined-field We only put this on the files adapter columns
|
|
if column and column.require_stat then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
---@param name string
|
|
---@return nil|oil.ColumnDefinition
|
|
M.get_column = function(name)
|
|
return file_columns[name]
|
|
end
|
|
|
|
---@param url string
|
|
---@param callback fun(url: string)
|
|
M.normalize_url = function(url, callback)
|
|
local scheme, path = util.parse_url(url)
|
|
assert(path)
|
|
|
|
if fs.is_windows then
|
|
if path == "/" then
|
|
return callback(url)
|
|
else
|
|
local is_root_drive = path:match("^/%u$")
|
|
if is_root_drive then
|
|
return callback(url .. "/")
|
|
end
|
|
end
|
|
end
|
|
|
|
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ":p")
|
|
uv.fs_realpath(os_path, function(err, new_os_path)
|
|
local realpath
|
|
if fs.is_windows then
|
|
-- Ignore the fs_realpath on windows because it will resolve mapped network drives to the IP
|
|
-- address instead of using the drive letter
|
|
realpath = os_path
|
|
else
|
|
realpath = new_os_path or os_path
|
|
end
|
|
|
|
uv.fs_stat(
|
|
realpath,
|
|
vim.schedule_wrap(function(stat_err, stat)
|
|
local is_directory
|
|
if stat then
|
|
is_directory = stat.type == "directory"
|
|
elseif vim.endswith(realpath, "/") or (fs.is_windows and vim.endswith(realpath, "\\")) then
|
|
is_directory = true
|
|
else
|
|
local filetype = vim.filetype.match({ filename = vim.fs.basename(realpath) })
|
|
is_directory = filetype == nil
|
|
end
|
|
|
|
if is_directory then
|
|
local norm_path = util.addslash(fs.os_to_posix_path(realpath))
|
|
callback(scheme .. norm_path)
|
|
else
|
|
callback(vim.fn.fnamemodify(realpath, ":."))
|
|
end
|
|
end)
|
|
)
|
|
end)
|
|
end
|
|
|
|
---@param url string
|
|
---@param entry oil.Entry
|
|
---@param cb fun(path: nil|string)
|
|
M.get_entry_path = function(url, entry, cb)
|
|
if entry.id then
|
|
local parent_url = cache.get_parent_url(entry.id)
|
|
local scheme, path = util.parse_url(parent_url)
|
|
M.normalize_url(scheme .. path .. entry.name, cb)
|
|
else
|
|
if entry.type == "directory" then
|
|
cb(url)
|
|
else
|
|
local _, path = util.parse_url(url)
|
|
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(assert(path)), ":p")
|
|
cb(os_path)
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param parent_dir string
|
|
---@param entry oil.InternalEntry
|
|
---@param require_stat boolean
|
|
---@param cb fun(err?: string)
|
|
local function fetch_entry_metadata(parent_dir, entry, require_stat, cb)
|
|
local entry_path = fs.posix_to_os_path(parent_dir .. entry[FIELD_NAME])
|
|
local meta = entry[FIELD_META]
|
|
if not meta then
|
|
meta = {}
|
|
entry[FIELD_META] = meta
|
|
end
|
|
|
|
-- Sometimes fs_readdir entries don't have a type, so we need to stat them.
|
|
-- See https://github.com/stevearc/oil.nvim/issues/543
|
|
if not require_stat and not entry[FIELD_TYPE] then
|
|
require_stat = true
|
|
end
|
|
|
|
-- Make sure we always get fs_stat info for links
|
|
if entry[FIELD_TYPE] == "link" then
|
|
read_link_data(entry_path, function(link_err, link, link_stat)
|
|
if link_err then
|
|
log.warn("Error reading link data %s: %s", entry_path, link_err)
|
|
return cb()
|
|
end
|
|
meta.link = link
|
|
if link_stat then
|
|
-- Use the fstat of the linked file as the stat for the link
|
|
meta.link_stat = link_stat
|
|
meta.stat = link_stat
|
|
elseif require_stat then
|
|
-- The link is broken, so let's use the stat of the link itself
|
|
uv.fs_lstat(entry_path, function(stat_err, stat)
|
|
if stat_err then
|
|
log.warn("Error lstat link file %s: %s", entry_path, stat_err)
|
|
return cb()
|
|
end
|
|
meta.stat = stat
|
|
cb()
|
|
end)
|
|
return
|
|
end
|
|
|
|
cb()
|
|
end)
|
|
elseif require_stat then
|
|
uv.fs_stat(entry_path, function(stat_err, stat)
|
|
if stat_err then
|
|
log.warn("Error stat file %s: %s", entry_path, stat_err)
|
|
return cb()
|
|
end
|
|
assert(stat)
|
|
entry[FIELD_TYPE] = stat.type
|
|
meta.stat = stat
|
|
cb()
|
|
end)
|
|
else
|
|
cb()
|
|
end
|
|
end
|
|
|
|
-- On windows, sometimes the entry type from fs_readdir is "link" but the actual type is not.
|
|
-- See https://github.com/stevearc/oil.nvim/issues/535
|
|
if fs.is_windows then
|
|
local old_fetch_metadata = fetch_entry_metadata
|
|
fetch_entry_metadata = function(parent_dir, entry, require_stat, cb)
|
|
if entry[FIELD_TYPE] == "link" then
|
|
local entry_path = fs.posix_to_os_path(parent_dir .. entry[FIELD_NAME])
|
|
uv.fs_lstat(entry_path, function(stat_err, stat)
|
|
if stat_err then
|
|
log.warn("Error lstat link file %s: %s", entry_path, stat_err)
|
|
return old_fetch_metadata(parent_dir, entry, require_stat, cb)
|
|
end
|
|
assert(stat)
|
|
entry[FIELD_TYPE] = stat.type
|
|
local meta = entry[FIELD_META]
|
|
if not meta then
|
|
meta = {}
|
|
entry[FIELD_META] = meta
|
|
end
|
|
meta.stat = stat
|
|
old_fetch_metadata(parent_dir, entry, require_stat, cb)
|
|
end)
|
|
else
|
|
return old_fetch_metadata(parent_dir, entry, require_stat, cb)
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param url string
|
|
---@param column_defs string[]
|
|
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
|
|
local function list_windows_drives(url, column_defs, cb)
|
|
local _, path = util.parse_url(url)
|
|
assert(path)
|
|
local require_stat = columns_require_stat(column_defs)
|
|
local stdout = ""
|
|
local jid = vim.fn.jobstart({ "wmic", "logicaldisk", "get", "name" }, {
|
|
stdout_buffered = true,
|
|
on_stdout = function(_, data)
|
|
stdout = table.concat(data, "\n")
|
|
end,
|
|
on_exit = function(_, code)
|
|
if code ~= 0 then
|
|
return cb("Error listing windows devices")
|
|
end
|
|
local lines = vim.split(stdout, "\n", { plain = true, trimempty = true })
|
|
-- Remove the "Name" header
|
|
table.remove(lines, 1)
|
|
local internal_entries = {}
|
|
local complete_disk_cb = util.cb_collect(#lines, function(err)
|
|
if err then
|
|
cb(err)
|
|
else
|
|
cb(nil, internal_entries)
|
|
end
|
|
end)
|
|
|
|
for _, disk in ipairs(lines) do
|
|
if disk:match("^%s*$") then
|
|
-- Skip empty line
|
|
complete_disk_cb()
|
|
else
|
|
disk = disk:gsub(":%s*$", "")
|
|
local cache_entry = cache.create_entry(url, disk, "directory")
|
|
table.insert(internal_entries, cache_entry)
|
|
fetch_entry_metadata(path, cache_entry, require_stat, complete_disk_cb)
|
|
end
|
|
end
|
|
end,
|
|
})
|
|
if jid <= 0 then
|
|
cb("Could not list windows devices")
|
|
end
|
|
end
|
|
|
|
---@param url string
|
|
---@param column_defs string[]
|
|
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
|
|
M.list = function(url, column_defs, cb)
|
|
local _, path = util.parse_url(url)
|
|
assert(path)
|
|
if fs.is_windows and path == "/" then
|
|
return list_windows_drives(url, column_defs, cb)
|
|
end
|
|
local dir = fs.posix_to_os_path(path)
|
|
local require_stat = columns_require_stat(column_defs)
|
|
|
|
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
|
|
uv.fs_opendir(dir, function(open_err, fd)
|
|
if open_err then
|
|
if open_err:match("^ENOENT: no such file or directory") then
|
|
-- If the directory doesn't exist, treat the list as a success. We will be able to traverse
|
|
-- and edit a not-yet-existing directory.
|
|
return cb()
|
|
else
|
|
return cb(open_err)
|
|
end
|
|
end
|
|
local read_next
|
|
read_next = function()
|
|
uv.fs_readdir(fd, function(err, entries)
|
|
local internal_entries = {}
|
|
if err then
|
|
uv.fs_closedir(fd, function()
|
|
cb(err)
|
|
end)
|
|
return
|
|
elseif entries then
|
|
local poll = util.cb_collect(#entries, function(inner_err)
|
|
if inner_err then
|
|
cb(inner_err)
|
|
else
|
|
cb(nil, internal_entries, read_next)
|
|
end
|
|
end)
|
|
for _, entry in ipairs(entries) do
|
|
local cache_entry = cache.create_entry(url, entry.name, entry.type)
|
|
table.insert(internal_entries, cache_entry)
|
|
fetch_entry_metadata(path, cache_entry, require_stat, poll)
|
|
end
|
|
else
|
|
uv.fs_closedir(fd, function(close_err)
|
|
if close_err then
|
|
cb(close_err)
|
|
else
|
|
cb()
|
|
end
|
|
end)
|
|
end
|
|
end)
|
|
end
|
|
read_next()
|
|
---@diagnostic disable-next-line: param-type-mismatch
|
|
end, 10000)
|
|
end
|
|
|
|
---@param bufnr integer
|
|
---@return boolean
|
|
M.is_modifiable = function(bufnr)
|
|
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
|
local _, path = util.parse_url(bufname)
|
|
assert(path)
|
|
if fs.is_windows and path == "/" then
|
|
return false
|
|
end
|
|
local dir = fs.posix_to_os_path(path)
|
|
local stat = uv.fs_stat(dir)
|
|
if not stat then
|
|
return true
|
|
end
|
|
|
|
-- fs_access can return nil, force boolean return
|
|
return uv.fs_access(dir, "W") == true
|
|
end
|
|
|
|
---@param action oil.Action
|
|
---@return string
|
|
M.render_action = function(action)
|
|
if action.type == "create" then
|
|
local _, path = util.parse_url(action.url)
|
|
assert(path)
|
|
local ret = string.format("CREATE %s", M.to_short_os_path(path, action.entry_type))
|
|
if action.link then
|
|
ret = ret .. " -> " .. fs.posix_to_os_path(action.link)
|
|
end
|
|
return ret
|
|
elseif action.type == "delete" then
|
|
local _, path = util.parse_url(action.url)
|
|
assert(path)
|
|
local short_path = M.to_short_os_path(path, action.entry_type)
|
|
if config.delete_to_trash then
|
|
return string.format(" TRASH %s", short_path)
|
|
else
|
|
return string.format("DELETE %s", short_path)
|
|
end
|
|
elseif action.type == "move" or action.type == "copy" then
|
|
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
|
if dest_adapter == M then
|
|
local _, src_path = util.parse_url(action.src_url)
|
|
assert(src_path)
|
|
local _, dest_path = util.parse_url(action.dest_url)
|
|
assert(dest_path)
|
|
return string.format(
|
|
" %s %s -> %s",
|
|
action.type:upper(),
|
|
M.to_short_os_path(src_path, action.entry_type),
|
|
M.to_short_os_path(dest_path, action.entry_type)
|
|
)
|
|
else
|
|
-- We should never hit this because we don't implement supported_cross_adapter_actions
|
|
error("files adapter doesn't support cross-adapter move/copy")
|
|
end
|
|
else
|
|
error(string.format("Bad action type: '%s'", action.type))
|
|
end
|
|
end
|
|
|
|
---@param action oil.Action
|
|
---@param cb fun(err: nil|string)
|
|
M.perform_action = function(action, cb)
|
|
if action.type == "create" then
|
|
local _, path = util.parse_url(action.url)
|
|
assert(path)
|
|
path = fs.posix_to_os_path(path)
|
|
|
|
if config.git.add(path) then
|
|
local old_cb = cb
|
|
cb = vim.schedule_wrap(function(err)
|
|
if not err then
|
|
git.add(path, old_cb)
|
|
else
|
|
old_cb(err)
|
|
end
|
|
end)
|
|
end
|
|
|
|
if action.entry_type == "directory" then
|
|
uv.fs_mkdir(path, config.new_dir_mode, function(err)
|
|
-- Ignore if the directory already exists
|
|
if not err or err:match("^EEXIST:") then
|
|
cb()
|
|
else
|
|
cb(err)
|
|
end
|
|
end)
|
|
elseif action.entry_type == "link" and action.link then
|
|
local flags = nil
|
|
local target = fs.posix_to_os_path(action.link)
|
|
if fs.is_windows then
|
|
flags = {
|
|
dir = vim.fn.isdirectory(target) == 1,
|
|
junction = false,
|
|
}
|
|
end
|
|
---@diagnostic disable-next-line: param-type-mismatch
|
|
uv.fs_symlink(target, path, flags, cb)
|
|
else
|
|
fs.touch(path, config.new_file_mode, cb)
|
|
end
|
|
elseif action.type == "delete" then
|
|
local _, path = util.parse_url(action.url)
|
|
assert(path)
|
|
path = fs.posix_to_os_path(path)
|
|
|
|
if config.git.rm(path) then
|
|
local old_cb = cb
|
|
cb = vim.schedule_wrap(function(err)
|
|
if not err then
|
|
git.rm(path, old_cb)
|
|
else
|
|
old_cb(err)
|
|
end
|
|
end)
|
|
end
|
|
|
|
if config.delete_to_trash then
|
|
require("oil.adapters.trash").delete_to_trash(path, cb)
|
|
else
|
|
fs.recursive_delete(action.entry_type, path, cb)
|
|
end
|
|
elseif action.type == "move" then
|
|
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
|
if dest_adapter == M then
|
|
local _, src_path = util.parse_url(action.src_url)
|
|
assert(src_path)
|
|
local _, dest_path = util.parse_url(action.dest_url)
|
|
assert(dest_path)
|
|
src_path = fs.posix_to_os_path(src_path)
|
|
dest_path = fs.posix_to_os_path(dest_path)
|
|
if config.git.mv(src_path, dest_path) then
|
|
git.mv(action.entry_type, src_path, dest_path, cb)
|
|
else
|
|
fs.recursive_move(action.entry_type, src_path, dest_path, cb)
|
|
end
|
|
else
|
|
-- We should never hit this because we don't implement supported_cross_adapter_actions
|
|
cb("files adapter doesn't support cross-adapter move")
|
|
end
|
|
elseif action.type == "copy" then
|
|
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
|
if dest_adapter == M then
|
|
local _, src_path = util.parse_url(action.src_url)
|
|
assert(src_path)
|
|
local _, dest_path = util.parse_url(action.dest_url)
|
|
assert(dest_path)
|
|
src_path = fs.posix_to_os_path(src_path)
|
|
dest_path = fs.posix_to_os_path(dest_path)
|
|
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
|
|
else
|
|
-- We should never hit this because we don't implement supported_cross_adapter_actions
|
|
cb("files adapter doesn't support cross-adapter copy")
|
|
end
|
|
else
|
|
cb(string.format("Bad action type: %s", action.type))
|
|
end
|
|
end
|
|
|
|
return M
|