local config = require("oil.config") local constants = require("oil.constants") local files = require("oil.adapters.files") local fs = require("oil.fs") local loading = require("oil.loading") local pathutil = require("oil.pathutil") local s3fs = require("oil.adapters.s3.s3fs") local util = require("oil.util") local M = {} local FIELD_META = constants.FIELD_META ---@class (exact) oil.s3Url ---@field scheme string ---@field bucket nil|string ---@field path nil|string ---@param oil_url string ---@return oil.s3Url M.parse_url = function(oil_url) local scheme, url = util.parse_url(oil_url) assert(scheme and url, string.format("Malformed input url '%s'", oil_url)) local ret = { scheme = scheme } local bucket, path = url:match("^([^/]+)/?(.*)$") ret.bucket = bucket ret.path = path ~= "" and path or nil if not ret.bucket and ret.path then error(string.format("Parsing error for s3 url: %s", oil_url)) end ---@cast ret oil.s3Url return ret end ---@param url oil.s3Url ---@return string local function url_to_str(url) local pieces = { url.scheme } if url.bucket then assert(url.bucket ~= "") table.insert(pieces, url.bucket) table.insert(pieces, "/") end if url.path then assert(url.path ~= "") table.insert(pieces, url.path) end return table.concat(pieces, "") end ---@param url oil.s3Url ---@param is_folder boolean ---@return string local function url_to_s3(url, is_folder) local pieces = { "s3://" } if url.bucket then assert(url.bucket ~= "") table.insert(pieces, url.bucket) table.insert(pieces, "/") end if url.path then assert(url.path ~= "") table.insert(pieces, url.path) if is_folder and not vim.endswith(url.path, "/") then table.insert(pieces, "/") end end return table.concat(pieces, "") end ---@param url oil.s3Url ---@return boolean local function is_bucket(url) assert(url.bucket and url.bucket ~= "") if url.path then assert(url.path ~= "") return false end return true end local s3_columns = {} s3_columns.size = { render = function(entry, conf) local meta = entry[FIELD_META] if not meta or not meta.size then return "" elseif meta.size >= 1e9 then return string.format("%.1fG", meta.size / 1e9) elseif meta.size >= 1e6 then return string.format("%.1fM", meta.size / 1e6) elseif meta.size >= 1e3 then return string.format("%.1fk", meta.size / 1e3) else return string.format("%d", meta.size) end end, parse = function(line, conf) return line:match("^(%d+%S*)%s+(.*)$") end, get_sort_value = function(entry) local meta = entry[FIELD_META] if meta and meta.size then return meta.size else return 0 end end, } s3_columns.birthtime = { render = function(entry, conf) local meta = entry[FIELD_META] if not meta or not meta.date then return "" else return meta.date end end, parse = function(line, conf) return line:match("^(%d+%-%d+%-%d+%s%d+:%d+:%d+)%s+(.*)$") end, get_sort_value = function(entry) local meta = entry[FIELD_META] if meta and meta.date then local year, month, day, hour, min, sec = meta.date:match("^(%d+)%-(%d+)%-(%d+)%s(%d+):(%d+):(%d+)$") local time = os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec }) return time else return 0 end end, } ---@param name string ---@return nil|oil.ColumnDefinition M.get_column = function(name) return s3_columns[name] end ---@param bufname string ---@return string M.get_parent = function(bufname) local res = M.parse_url(bufname) if res.path then assert(res.path ~= "") local path = pathutil.parent(res.path) res.path = path ~= "" and path or nil else res.bucket = nil end return url_to_str(res) end ---@param url string ---@param callback fun(url: string) M.normalize_url = function(url, callback) local res = M.parse_url(url) callback(url_to_str(res)) end ---@param url string ---@param column_defs string[] ---@param callback fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun()) M.list = function(url, column_defs, callback) if vim.fn.executable("aws") ~= 1 then callback("`aws` is not executable. Can you run `aws s3 ls`?") return end local res = M.parse_url(url) s3fs.list_dir(url, url_to_s3(res, true), callback) end ---@param bufnr integer ---@return boolean M.is_modifiable = function(bufnr) -- default assumption is that everything is modifiable return true end ---@param action oil.Action ---@return string M.render_action = function(action) local is_folder = action.entry_type == "directory" if action.type == "create" then local res = M.parse_url(action.url) local extra = is_bucket(res) and "BUCKET " or "" return string.format("CREATE %s%s", extra, url_to_s3(res, is_folder)) elseif action.type == "delete" then local res = M.parse_url(action.url) local extra = is_bucket(res) and "BUCKET " or "" return string.format("DELETE %s%s", extra, url_to_s3(res, is_folder)) elseif action.type == "move" or action.type == "copy" then local src = action.src_url local dest = action.dest_url if config.get_adapter_by_scheme(src) ~= M then local _, path = util.parse_url(src) assert(path) src = files.to_short_os_path(path, action.entry_type) dest = url_to_s3(M.parse_url(dest), is_folder) elseif config.get_adapter_by_scheme(dest) ~= M then local _, path = util.parse_url(dest) assert(path) dest = files.to_short_os_path(path, action.entry_type) src = url_to_s3(M.parse_url(src), is_folder) end return string.format(" %s %s -> %s", action.type:upper(), src, dest) 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) local is_folder = action.entry_type == "directory" if action.type == "create" then local res = M.parse_url(action.url) local bucket = is_bucket(res) if action.entry_type == "directory" and bucket then s3fs.mb(url_to_s3(res, true), cb) elseif action.entry_type == "directory" or action.entry_type == "file" then s3fs.touch(url_to_s3(res, is_folder), cb) else cb(string.format("Bad entry type on s3 create action: %s", action.entry_type)) end elseif action.type == "delete" then local res = M.parse_url(action.url) local bucket = is_bucket(res) if action.entry_type == "directory" and bucket then s3fs.rb(url_to_s3(res, true), cb) elseif action.entry_type == "directory" or action.entry_type == "file" then s3fs.rm(url_to_s3(res, is_folder), is_folder, cb) else cb(string.format("Bad entry type on s3 delete action: %s", action.entry_type)) end 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 ~= M and src_adapter ~= files) or (dest_adapter ~= M and dest_adapter ~= files) then cb( string.format( "We should never attempt to move from the %s adapter to the %s adapter.", src_adapter.name, dest_adapter.name ) ) end local src, _ if src_adapter == M then local src_res = M.parse_url(action.src_url) src = url_to_s3(src_res, is_folder) else _, src = util.parse_url(action.src_url) end assert(src) local dest if dest_adapter == M then local dest_res = M.parse_url(action.dest_url) dest = url_to_s3(dest_res, is_folder) else _, dest = util.parse_url(action.dest_url) end assert(dest) s3fs.mv(src, dest, is_folder, cb) elseif 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)) if (src_adapter ~= M and src_adapter ~= files) or (dest_adapter ~= M and dest_adapter ~= files) then cb( string.format( "We should never attempt to copy from the %s adapter to the %s adapter.", src_adapter.name, dest_adapter.name ) ) end local src, _ if src_adapter == M then local src_res = M.parse_url(action.src_url) src = url_to_s3(src_res, is_folder) else _, src = util.parse_url(action.src_url) end assert(src) local dest if dest_adapter == M then local dest_res = M.parse_url(action.dest_url) dest = url_to_s3(dest_res, is_folder) else _, dest = util.parse_url(action.dest_url) end assert(dest) s3fs.cp(src, dest, is_folder, cb) else cb(string.format("Bad action type: %s", action.type)) end end M.supported_cross_adapter_actions = { files = "move" } ---@param bufnr integer M.read_file = function(bufnr) loading.set_loading(bufnr, true) local bufname = vim.api.nvim_buf_get_name(bufnr) local url = M.parse_url(bufname) local basename = pathutil.basename(bufname) local cache_dir = vim.fn.stdpath("cache") assert(type(cache_dir) == "string") local tmpdir = fs.join(cache_dir, "oil") fs.mkdirp(tmpdir) local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, "s3_XXXXXX")) if fd then vim.loop.fs_close(fd) end local tmp_bufnr = vim.fn.bufadd(tmpfile) s3fs.cp(url_to_s3(url, false), tmpfile, false, function(err) loading.set_loading(bufnr, false) vim.bo[bufnr].modifiable = true vim.cmd.doautocmd({ args = { "BufReadPre", bufname }, mods = { silent = true } }) if err then vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, vim.split(err, "\n")) else vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, {}) vim.api.nvim_buf_call(bufnr, function() vim.cmd.read({ args = { tmpfile }, mods = { silent = true } }) end) vim.loop.fs_unlink(tmpfile) vim.api.nvim_buf_set_lines(bufnr, 0, 1, true, {}) end vim.bo[bufnr].modified = false local filetype = vim.filetype.match({ buf = bufnr, filename = basename }) if filetype then vim.bo[bufnr].filetype = filetype end vim.cmd.doautocmd({ args = { "BufReadPost", bufname }, mods = { silent = true } }) vim.api.nvim_buf_delete(tmp_bufnr, { force = true }) end) end ---@param bufnr integer M.write_file = function(bufnr) local bufname = vim.api.nvim_buf_get_name(bufnr) local url = M.parse_url(bufname) local cache_dir = vim.fn.stdpath("cache") assert(type(cache_dir) == "string") local tmpdir = fs.join(cache_dir, "oil") local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, "s3_XXXXXXXX")) if fd then vim.loop.fs_close(fd) end vim.cmd.doautocmd({ args = { "BufWritePre", bufname }, mods = { silent = true } }) vim.bo[bufnr].modifiable = false vim.cmd.write({ args = { tmpfile }, bang = true, mods = { silent = true, noautocmd = true } }) local tmp_bufnr = vim.fn.bufadd(tmpfile) s3fs.cp(tmpfile, url_to_s3(url, false), false, function(err) vim.bo[bufnr].modifiable = true if err then vim.notify(string.format("Error writing file: %s", err), vim.log.levels.ERROR) else vim.bo[bufnr].modified = false vim.cmd.doautocmd({ args = { "BufWritePost", bufname }, mods = { silent = true } }) end vim.loop.fs_unlink(tmpfile) vim.api.nvim_buf_delete(tmp_bufnr, { force = true }) end) end return M