local SSHConnection = require('oil.adapters.ssh.connection') local cache = require('oil.cache') local constants = require('oil.constants') local permissions = require('oil.adapters.files.permissions') local util = require('oil.util') ---@class (exact) oil.sshFs ---@field new fun(url: oil.sshUrl): oil.sshFs ---@field conn oil.sshConnection local SSHFS = {} local FIELD_TYPE = constants.FIELD_TYPE local FIELD_META = constants.FIELD_META local typechar_map = { l = 'link', d = 'directory', p = 'fifo', s = 'socket', ['-'] = 'file', c = 'file', -- character special file b = 'file', -- block special file } ---@param line string ---@return string Name of entry ---@return oil.EntryType ---@return table Metadata for entry local function parse_ls_line(line) local typechar, perms, refcount, user, group, rem = line:match('^(.)(%S+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(.*)$') if not typechar then error(string.format("Could not parse '%s'", line)) end local type = typechar_map[typechar] or 'file' local meta = { user = user, group = group, mode = permissions.parse(perms), refcount = tonumber(refcount), } local name, size, date, major, minor if typechar == 'c' or typechar == 'b' then major, minor, date, name = rem:match('^(%d+)%s*,%s*(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)') if name == nil then major, minor, date, name = rem:match('^(%d+)%s*,%s*(%d+)%s+(%d+%-%d+%-%d+%s+%d%d:?%d%d)%s+(.*)') end meta.major = tonumber(major) meta.minor = tonumber(minor) else size, date, name = rem:match('^(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)') if name == nil then size, date, name = rem:match('^(%d+)%s+(%d+%-%d+%-%d+%s+%d%d:?%d%d)%s+(.*)') end meta.size = tonumber(size) end meta.iso_modified_date = date if type == 'link' then local link name, link = unpack(vim.split(name, ' -> ', { plain = true })) if vim.endswith(link, '/') then link = link:sub(1, #link - 1) end meta.link = link end return name, type, meta end ---@param str string String to escape ---@return string Escaped string local function shellescape(str) return "'" .. str:gsub("'", "'\\''") .. "'" end ---@param url oil.sshUrl ---@return oil.sshFs function SSHFS.new(url) ---@type oil.sshFs return setmetatable({ conn = SSHConnection.new(url), }, { __index = SSHFS, }) end function SSHFS:get_connection_error() return self.conn.connection_error end ---@param value integer ---@param path string ---@param callback fun(err: nil|string) function SSHFS:chmod(value, path, callback) local octal = permissions.mode_to_octal_str(value) self.conn:run(string.format('chmod %s %s', octal, shellescape(path)), callback) end function SSHFS:open_terminal() self.conn:open_terminal() end function SSHFS:realpath(path, callback) local cmd = string.format( 'if ! readlink -f "%s" 2>/dev/null; then [[ "%s" == /* ]] && echo "%s" || echo "$PWD/%s"; fi', path, path, path, path ) self.conn:run(cmd, function(err, lines) if err then return callback(err) end assert(lines) local abspath = table.concat(lines, '') -- If the path was "." then the abspath might be /path/to/., so we need to trim that final '.' if vim.endswith(abspath, '.') then abspath = abspath:sub(1, #abspath - 1) end self.conn:run( string.format('LC_ALL=C ls -land --color=never %s', shellescape(abspath)), function(ls_err, ls_lines) local type if ls_err then -- If the file doesn't exist, treat it like a not-yet-existing directory type = 'directory' else assert(ls_lines) local _ _, type = parse_ls_line(ls_lines[1]) end if type == 'directory' then abspath = util.addslash(abspath) end callback(nil, abspath) end ) end) end local dir_meta = {} ---@param url string ---@param path string ---@param callback fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun()) function SSHFS:list_dir(url, path, callback) local path_postfix = '' if path ~= '' then path_postfix = string.format(' %s', shellescape(path)) end self.conn:run('LC_ALL=C ls -lan --color=never' .. path_postfix, function(err, lines) if err then if err:match('No such file or directory%s*$') 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 callback() else return callback(err) end end assert(lines) local any_links = false local entries = {} local cache_entries = {} for _, line in ipairs(lines) do if line ~= '' and not line:match('^total') then local name, type, meta = parse_ls_line(line) if name == '.' then dir_meta[url] = meta elseif name ~= '..' then if type == 'link' then any_links = true end local cache_entry = cache.create_entry(url, name, type) table.insert(cache_entries, cache_entry) entries[name] = cache_entry cache_entry[FIELD_META] = meta end end end if any_links then -- If there were any soft links, then we need to run another ls command with -L so that we can -- resolve the type of the link target self.conn:run( 'LC_ALL=C ls -naLl --color=never' .. path_postfix .. ' 2> /dev/null', function(link_err, link_lines) -- Ignore exit code 1. That just means one of the links could not be resolved. if link_err and not link_err:match('^1:') then return callback(link_err) end assert(link_lines) for _, line in ipairs(link_lines) do if line ~= '' and not line:match('^total') then local ok, name, type, meta = pcall(parse_ls_line, line) if ok and name ~= '.' and name ~= '..' then local cache_entry = entries[name] if cache_entry[FIELD_TYPE] == 'link' then cache_entry[FIELD_META].link_stat = { type = type, size = meta.size, } end end end end callback(nil, cache_entries) end ) else callback(nil, cache_entries) end end) end ---@param path string ---@param callback fun(err: nil|string) function SSHFS:mkdir(path, callback) self.conn:run(string.format('mkdir -p %s', shellescape(path)), callback) end ---@param path string ---@param callback fun(err: nil|string) function SSHFS:touch(path, callback) self.conn:run(string.format('touch %s', shellescape(path)), callback) end ---@param path string ---@param link string ---@param callback fun(err: nil|string) function SSHFS:mklink(path, link, callback) self.conn:run(string.format('ln -s %s %s', shellescape(link), shellescape(path)), callback) end ---@param path string ---@param callback fun(err: nil|string) function SSHFS:rm(path, callback) self.conn:run(string.format('rm -rf %s', shellescape(path)), callback) end ---@param src string ---@param dest string ---@param callback fun(err: nil|string) function SSHFS:mv(src, dest, callback) self.conn:run(string.format('mv %s %s', shellescape(src), shellescape(dest)), callback) end ---@param src string ---@param dest string ---@param callback fun(err: nil|string) function SSHFS:cp(src, dest, callback) self.conn:run(string.format('cp -r %s %s', shellescape(src), shellescape(dest)), callback) end function SSHFS:get_dir_meta(url) return dir_meta[url] end function SSHFS:get_meta() return self.conn.meta end return SSHFS