canola.nvim/lua/oil/adapters/trash/mac.lua
Barrett Ruth c6b4a7a07b
feat: add configurable file and directory creation permissions
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
2026-02-20 20:26:07 -05:00

232 lines
7 KiB
Lua

local cache = require("oil.cache")
local config = require("oil.config")
local files = require("oil.adapters.files")
local fs = require("oil.fs")
local util = require("oil.util")
local uv = vim.uv or vim.loop
local M = {}
local function touch_dir(path)
uv.fs_mkdir(path, 448) -- 0700
end
---Gets the location of the home trash dir, creating it if necessary
---@return string
local function get_trash_dir()
local trash_dir = fs.join(assert(uv.os_homedir()), ".Trash")
touch_dir(trash_dir)
return trash_dir
end
---@param url string
---@param callback fun(url: string)
M.normalize_url = function(url, callback)
local scheme, path = util.parse_url(url)
assert(path)
callback(scheme .. "/")
end
---@param url string
---@param entry oil.Entry
---@param cb fun(path: string)
M.get_entry_path = function(url, entry, cb)
local trash_dir = get_trash_dir()
local path = fs.join(trash_dir, entry.name)
if entry.type == "directory" then
path = "oil://" .. path
end
cb(path)
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)
cb = vim.schedule_wrap(cb)
local _, path = util.parse_url(url)
assert(path)
local trash_dir = get_trash_dir()
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
uv.fs_opendir(trash_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)
if err then
uv.fs_closedir(fd, function()
cb(err)
end)
return
elseif entries then
local internal_entries = {}
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
-- TODO: read .DS_Store and filter by original dir
local cache_entry = cache.create_entry(url, entry.name, entry.type)
table.insert(internal_entries, cache_entry)
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)
return true
end
---@param name string
---@return nil|oil.ColumnDefinition
M.get_column = function(name)
return nil
end
M.supported_cross_adapter_actions = { files = "move" }
---@param action oil.Action
---@return string
M.render_action = function(action)
if action.type == "create" then
return string.format("CREATE %s", action.url)
elseif action.type == "delete" then
return string.format(" PURGE %s", action.url)
elseif action.type == "move" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == "files" then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(" TRASH %s", short_path)
elseif dest_adapter.name == "files" then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format("RESTORE %s", short_path)
else
return string.format(" %s %s -> %s", action.type:upper(), action.src_url, action.dest_url)
end
elseif action.type == "copy" then
return string.format(" %s %s -> %s", action.type:upper(), action.src_url, action.dest_url)
else
error("Bad action type")
end
end
---@param action oil.Action
---@param cb fun(err: nil|string)
M.perform_action = function(action, cb)
local trash_dir = get_trash_dir()
if action.type == "create" then
local _, path = util.parse_url(action.url)
assert(path)
path = trash_dir .. path
if action.entry_type == "directory" then
uv.fs_mkdir(path, 493, function(err)
-- Ignore if the directory already exists
if not err or err:match("^EEXIST:") then
cb()
else
cb(err)
end
end) -- 0755
elseif action.entry_type == "link" and action.link then
local flags = nil
local target = fs.posix_to_os_path(action.link)
---@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)
local fullpath = trash_dir .. path
fs.recursive_delete(action.entry_type, fullpath, cb)
elseif action.type == "move" or action.type == "copy" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
local _, src_path = util.parse_url(action.src_url)
local _, dest_path = util.parse_url(action.dest_url)
assert(src_path and dest_path)
if src_adapter.name == "files" then
dest_path = trash_dir .. dest_path
elseif dest_adapter.name == "files" then
src_path = trash_dir .. src_path
else
dest_path = trash_dir .. dest_path
src_path = trash_dir .. src_path
end
if action.type == "move" then
fs.recursive_move(action.entry_type, src_path, dest_path, cb)
else
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
end
else
cb(string.format("Bad action type: %s", action.type))
end
end
---@param path string
---@param cb fun(err?: string)
M.delete_to_trash = function(path, cb)
local basename = vim.fs.basename(path)
local trash_dir = get_trash_dir()
local dest = fs.join(trash_dir, basename)
uv.fs_lstat(
path,
vim.schedule_wrap(function(stat_err, src_stat)
if stat_err then
return cb(stat_err)
end
assert(src_stat)
if uv.fs_lstat(dest) then
local date_str = vim.fn.strftime(" %Y-%m-%dT%H:%M:%S")
local name_pieces = vim.split(basename, ".", { plain = true })
if #name_pieces > 1 then
table.insert(name_pieces, #name_pieces - 1, date_str)
basename = table.concat(name_pieces)
else
basename = basename .. date_str
end
dest = fs.join(trash_dir, basename)
end
local stat_type = src_stat.type
fs.recursive_move(stat_type, path, dest, vim.schedule_wrap(cb))
end)
)
end
return M