├── LICENSE ├── README.md ├── dev └── init.lua ├── lua └── sshfs │ ├── config.lua │ ├── connections.lua │ ├── init.lua │ ├── utils.lua │ └── window.lua ├── plugin └── commands.vim └── preview.png /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Daniel Weidinger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim sshfs 2 | A wrapper around sshfs for fast remote exploration and editing. 3 | 4 | 5 | ![Project Preview](preview.png) 6 | 7 | ## Features 8 | - [x] fast connections to a remote host from within nvim 9 | - [x] ~/.ssh/config support 10 | - [x] check the connection status of existing mounts 11 | - [x] disconnect from mounts 12 | - [x] (re-)Connect to existing mounts 13 | - [x] NERDTree integration 14 | 15 | ## Usage 16 | **Commands** 17 | * :SSHFSOpenHosts 18 | * :SSHFSOpenQuickConnect (Experimental) 19 | 20 | ### Keybindings for host window 21 | * q: close hosts window 22 | * d: disconnect from host under the cursor 23 | * \: connect with host under the cursor 24 | 25 | 26 | ## Setup 27 | VimPlug: 28 | ``` 29 | Plug 'DanielWeidinger/nvim-sshfs' 30 | ``` 31 | In order for this PlugIn to work you have to enable the "user_allow_other" option in your ``/etc/fuse.conf`` file. 32 | This allows for changes to be made to the files without starting sshfs as root 33 | ``` 34 | # /etc/fuse.conf - Configuration file for Filesystem in Userspace (FUSE) 35 | 36 | # Set the maximum number of FUSE mounts allowed to non-root users. 37 | # The default is 1000. 38 | # mount_max = 1000 39 | 40 | # Allow non-root users to specify the allow_other or allow_root mount options. 41 | user_allow_other <-- Add this line 42 | ``` 43 | ### Config 44 | default config: 45 | ``` 46 | require("sshfs").setup { 47 | mnt_base_dir = vim.fn.expand("$HOME") .. "/mnt", 48 | width = 0.6, -- host window width 49 | height = 0.5, -- host window height 50 | connection_icon = "✓", -- icon for connection indication 51 | } 52 | ``` 53 | 54 | ## Requirements 55 | * neovim >= 0.5 56 | * sshfs 57 | 58 | 59 | TODO: warn about the dangers of the default configuration of this plugin: https://askubuntu.com/questions/123072/ssh-automatically-accept-keys 60 | -------------------------------------------------------------------------------- /dev/init.lua: -------------------------------------------------------------------------------- 1 | -- force lua to import the modules again 2 | package.loaded["dev"] = nil 3 | package.loaded["sshfs"] = nil 4 | package.loaded["sshfs.window"] = nil 5 | package.loaded["sshfs.utils"] = nil 6 | package.loaded["sshfs.config"] = nil 7 | package.loaded["sshfs.connections"] = nil 8 | 9 | -- [ , + r ] keymap to reload the lua file 10 | -- NOTE: someone need to source this file to apply these configurations. So, the 11 | -- very first time you open the project, you have to source this file using 12 | -- ":luafile dev/init.lua". From that point onward, you can hit the keybind to 13 | -- reload 14 | vim.api.nvim_set_keymap("n", ",r", "luafile dev/init.lua", {}) 15 | -- vim.api.nvim_set_keymap('n', ',m', 'lua require("sshfs").open_hosts()', {}) 16 | vim.api.nvim_set_keymap("n", ",m", 'lua require("sshfs").open_hosts()', {}) 17 | -------------------------------------------------------------------------------- /lua/sshfs/config.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local defaults = { 4 | mnt_base_dir = vim.fn.expand("$HOME") .. "/mnt", 5 | width = 0.6, 6 | height = 0.5, 7 | connection_icon = "✓", 8 | } 9 | 10 | M.options = {} 11 | 12 | M.setup = function(options) 13 | if not options then 14 | M.options = defaults 15 | else 16 | for key, value in pairs(defaults) do 17 | M.options[key] = options[key] or value 18 | end 19 | end 20 | end 21 | 22 | return M 23 | -------------------------------------------------------------------------------- /lua/sshfs/connections.lua: -------------------------------------------------------------------------------- 1 | local utils = require("sshfs.utils") 2 | 3 | local M = {} 4 | 5 | M.connect_to_host = function(host) 6 | if not host.connected then 7 | if vim.fn.isdirectory(host.mnt_path) == 0 then 8 | print(host.mnt_path .. " does not exists.") 9 | local cwd_choice = vim.fn.input("Should " .. host.mnt_path .. " be created[Y/n]?\n") 10 | print("") 11 | if cwd_choice == "" or cwd_choice == "Y" or cwd_choice == "y" then 12 | vim.fn.mkdir(host.mnt_path, "p") 13 | else 14 | return 15 | end 16 | end 17 | 18 | local passwd = vim.fn.input("Password for " .. host["host"] .. "( for ssh-key auth): \n") 19 | local cmdParams = { host = (host["host"] or host), mnt_dir = host.mnt_path } 20 | if passwd == "" then 21 | cmdParams.key_auth = true 22 | end 23 | local cmd = utils.commands.mountHost(cmdParams) 24 | print("") 25 | 26 | local res = vim.fn.system(cmd, passwd) 27 | if res ~= "" then 28 | print(res) 29 | return 30 | end 31 | end 32 | 33 | print("Successfully " .. (host.connected and "re" or "") .. "connected!") 34 | local cwd_choice = vim.fn.input("Do you want to switch you cwd[Y/n]?\n") 35 | print("") 36 | if cwd_choice == "" or cwd_choice == "Y" or cwd_choice == "y" then 37 | vim.fn.chdir(host.mnt_path) 38 | if vim.fn.exists("NERDTree") == 1 then 39 | vim.cmd("NERDTreeCWD") 40 | end 41 | end 42 | end 43 | 44 | M.disconnect_from_host = function(host) 45 | local cmd = utils.commands.unmountFolder(host.mnt_path) 46 | local result = vim.fn.system(cmd) 47 | print(result) 48 | end 49 | 50 | return M 51 | -------------------------------------------------------------------------------- /lua/sshfs/init.lua: -------------------------------------------------------------------------------- 1 | local utils = require("sshfs.utils") 2 | local conn = require("sshfs.connections") 3 | local windows = require("sshfs.window") 4 | local config = require("sshfs.config") 5 | 6 | local M = {} 7 | 8 | local win, host_count, hosts_map 9 | local top_offset = 4 10 | 11 | M.setup = config.setup 12 | 13 | M.move_cursor = function(direction) 14 | local new_pos = math.max(top_offset, vim.api.nvim_win_get_cursor(win)[1] - direction) -- lower bound 15 | new_pos = math.min(top_offset + host_count - 1, new_pos) -- upper bound 16 | vim.api.nvim_win_set_cursor(win, { new_pos, 1 }) 17 | end 18 | 19 | M.close_window = function() 20 | vim.api.nvim_win_close(win, true) 21 | end 22 | 23 | M.open_host = function() 24 | local row = vim.api.nvim_win_get_cursor(win)[1] + 1 25 | local idx = row - top_offset 26 | local host = hosts_map[idx] 27 | 28 | vim.api.nvim_win_close(vim.api.nvim_get_current_win(), false) 29 | 30 | conn.connect_to_host(host) 31 | end 32 | 33 | M.open_hosts = function() 34 | local dims = windows.get_dimensions() 35 | local legend_content = utils.generate_legend(windows.mappings, dims.win_width) 36 | 37 | local buf, _win = windows.open_window(legend_content, dims) 38 | win = _win 39 | 40 | local hosts = vim.fn.systemlist(utils.commands.getAllHosts) 41 | local connections = vim.fn.systemlist(utils.commands.getAllConnections) 42 | 43 | local connections_map = utils.parse_connections(connections) 44 | hosts_map = utils.parse_config(hosts, connections_map) 45 | host_count = vim.fn.len(hosts_map) 46 | 47 | local host_content = utils.formatted_lines(hosts_map) 48 | 49 | vim.api.nvim_buf_set_option(buf, "modifiable", false) 50 | windows.set_header(buf, "Hosts") 51 | windows.set_content(buf, top_offset, host_content) 52 | vim.api.nvim_win_set_cursor(win, { top_offset, 1 }) 53 | windows.set_mappings(buf) 54 | end 55 | 56 | M.disconnect = function() 57 | local row = vim.api.nvim_win_get_cursor(win)[1] + 1 58 | local idx = row - top_offset 59 | local host = hosts_map[idx] 60 | 61 | if host.connected then 62 | conn.disconnect_from_host(host) 63 | M.close_window() 64 | M.open_hosts() 65 | else 66 | print("Host not connected!") 67 | end 68 | end 69 | 70 | M.open_quick_connect = function() 71 | local mnt_path = vim.fn.input("Mount path:\n") 72 | print("") 73 | local connection_str = vim.fn.input("Connection string[@]:\n") 74 | print("") 75 | local dflt_path = vim.fn.input("Path in remote host:\n") 76 | print("") 77 | local ftp_connection_str = connection_str .. ":" .. dflt_path 78 | 79 | local connections = vim.fn.systemlist(utils.commands.getAllConnections) 80 | local host = { 81 | mnt_path = mnt_path, 82 | host = ftp_connection_str, 83 | connected = (connections[connection_str] and true or false), 84 | } -- TODO: alias ssh/config hosts with connection_str 85 | 86 | conn.connect_to_host(host) 87 | end 88 | 89 | M.setup() 90 | return M 91 | -------------------------------------------------------------------------------- /lua/sshfs/utils.lua: -------------------------------------------------------------------------------- 1 | local config = require("sshfs.config") 2 | 3 | local M = {} 4 | 5 | M.commands = { 6 | getAllHosts = "cat $HOME/.ssh/config", 7 | getAllConnections = "mount -t fuse.sshfs", 8 | unmountFolder = function(path) 9 | return "fusermount -u " .. path 10 | end, 11 | mountHost = function(params) 12 | params.dflt_path = params.dflt_path or "/" --optinal arg 13 | params.exploration_only = params.exploration_only or false 14 | params.key_auth = params.key_auth or false 15 | 16 | local allow_other = (not params.exploration_only and " -o allow_other " or "") 17 | local password_stdin = (params.key_auth and "" or " -o password_stdin ") 18 | local ssh_cmd = " -o ssh_command='ssh -o StrictHostKeyChecking=accept-new' " 19 | 20 | -- if type(host) ~= "string" then 21 | -- host = host.user .. "@" .. host.hostname 22 | -- end 23 | local cmd = "sshfs " 24 | .. params.host 25 | .. ":" 26 | .. params.dflt_path 27 | .. " " 28 | .. params.mnt_dir 29 | .. allow_other 30 | .. password_stdin 31 | .. ssh_cmd 32 | 33 | return cmd 34 | end, 35 | } 36 | 37 | M.parse_config = function(config_lines, connections) 38 | local hostPattern = "Host%s+([%w%.-_]+)" 39 | local hostNamePattern = "Host[Nn]ame%s+([%w%.-_]+)" 40 | local userPattern = "User%s+([%w%.-_]+)" 41 | 42 | local result = {} 43 | local res_idx = 1 44 | 45 | local i = 0 46 | local line 47 | repeat 48 | i = i + 1 49 | line = config_lines[i] 50 | 51 | if line:find(hostPattern) then 52 | local host = line:match(hostPattern) 53 | local hostname, user 54 | 55 | i = i + 1 56 | line = config_lines[i] 57 | while line do 58 | if line:find(hostPattern) then 59 | i = i - 1 60 | break 61 | elseif line:find(hostNamePattern) then 62 | hostname = line:match(hostNamePattern) 63 | elseif line:find(userPattern) then 64 | user = line:match(userPattern) 65 | end 66 | 67 | i = i + 1 68 | line = config_lines[i] 69 | end 70 | 71 | local mnt_path = connections[host] or (config.options.mnt_base_dir .. "/" .. host) 72 | result[res_idx] = { 73 | host = host, 74 | hostname = hostname, 75 | user = user, 76 | mnt_path = mnt_path, 77 | connected = (connections[host] and true or false), 78 | } 79 | res_idx = res_idx + 1 80 | end 81 | 82 | until not line 83 | 84 | return result 85 | end 86 | 87 | M.parse_connections = function(connection_lines) 88 | local connection_pattern = "^([%w%.-_]+):/ on .*$" 89 | local connection_path_pattern = "^[%w%.-_]+:/ on ([/%w%.-_%d]+) type .*$" 90 | 91 | local results = {} 92 | 93 | for _, line in pairs(connection_lines) do 94 | local matches = line:match(connection_pattern) 95 | if matches then 96 | local conn_path = line:match(connection_path_pattern) 97 | if not conn_path then 98 | error("Connection detected but no path. Regex is probably faulty") 99 | end 100 | results[matches] = conn_path 101 | end 102 | end 103 | 104 | return results 105 | end 106 | 107 | M.formatted_lines = function(entries, win) 108 | -- TODO: baseline hostnames for windows 109 | -- TODO: Add connection indication heading 110 | local width = vim.api.nvim_win_get_width(win or 0) 111 | local str_entries = vim.fn.map(entries, function(i, e) 112 | local base = "[" .. i .. "] " .. e.host .. ": " .. e.user .. "@" .. e.hostname .. " --> " .. e.mnt_path 113 | local len = vim.fn.len(base) 114 | local connected_string = " [" .. (e.connected and config.options.connection_icon or " ") .. "]" 115 | local appendix = string.rep(" ", width - len - 4) 116 | return base .. appendix .. connected_string 117 | end) 118 | 119 | return str_entries 120 | end 121 | 122 | M.generate_legend = function(mappings, width) 123 | local fn_name_pattern = "^([%w%.-_]+)%(.+" 124 | 125 | local fn_set = {} 126 | for key, value in pairs(mappings) do 127 | local fn_name = value:match(fn_name_pattern) 128 | 129 | if fn_name then 130 | fn_name = fn_name:gsub("_", " ") 131 | if fn_set[fn_name] ~= nil then 132 | fn_set[fn_name] = fn_set[fn_name] .. ", " .. key 133 | else 134 | fn_set[fn_name] = key 135 | end 136 | end 137 | end 138 | 139 | local result = {} 140 | local idx = 1 141 | local line = "" 142 | for key, value in pairs(fn_set) do 143 | local appenix = key .. " -> " .. value 144 | local new_line_len = string.len(appenix) + string.len(line) 145 | if new_line_len >= (width - 3) then 146 | result[idx] = line 147 | idx = idx + 1 148 | line = appenix 149 | else 150 | line = line .. ((line ~= "") and " | " or "") .. appenix 151 | end 152 | end 153 | result[idx] = line 154 | result[idx + 1] = "" 155 | 156 | return result 157 | end 158 | 159 | M.concat_lines = function(t1, t2) 160 | local result = {} 161 | local idx = 0 162 | local add_fn = function(t) 163 | for _, value in pairs(t) do 164 | table.insert(result, value) 165 | idx = idx + 1 166 | end 167 | end 168 | 169 | add_fn(t1) 170 | add_fn(t2) 171 | 172 | return result 173 | end 174 | 175 | return M 176 | -------------------------------------------------------------------------------- /lua/sshfs/window.lua: -------------------------------------------------------------------------------- 1 | local config = require("sshfs.config") 2 | 3 | local M = {} 4 | -- credit goes out to: https://www.2n.pl/blog/how-to-write-neovim-plugins-in-lua 5 | 6 | M.center = function(str, win) 7 | win = win or 0 8 | local width = vim.api.nvim_win_get_width(win) 9 | local shift = math.floor(width / 2) - math.floor(string.len(str) / 2) 10 | return string.rep(" ", shift) .. str 11 | end 12 | 13 | M.set_header = function(buf, header) 14 | vim.api.nvim_buf_set_option(buf, "modifiable", true) 15 | 16 | local underline = "" 17 | for _ = 1, vim.fn.len(header) do 18 | underline = "-" .. underline 19 | end 20 | vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 21 | M.center(header), 22 | M.center(underline), 23 | M.center(""), 24 | }) 25 | 26 | vim.api.nvim_buf_set_option(buf, "modifiable", false) 27 | end 28 | 29 | M.set_content = function(buf, top_offset, content) 30 | vim.api.nvim_buf_set_option(buf, "modifiable", true) 31 | vim.api.nvim_buf_set_lines(buf, top_offset, -1, false, content) 32 | vim.api.nvim_buf_set_option(buf, "modifiable", false) 33 | end 34 | 35 | local open_legend_window = function(legend_content, win_width, win_height, row, col) 36 | local legend_len = vim.fn.len(legend_content) 37 | local opts = { 38 | style = "minimal", 39 | relative = "editor", 40 | width = win_width, 41 | height = legend_len, 42 | row = row + (win_height - legend_len), 43 | col = col, 44 | } 45 | 46 | local border_lines = { string.rep("-", win_width) } 47 | local legend_buf = vim.api.nvim_create_buf(false, true) 48 | vim.api.nvim_buf_set_lines(legend_buf, 0, -1, false, border_lines) 49 | vim.api.nvim_buf_set_lines(legend_buf, 1, -1, false, legend_content) 50 | local legend_win = vim.api.nvim_open_win(legend_buf, true, opts) 51 | 52 | return legend_buf, legend_win 53 | end 54 | 55 | local open_border_window = function(win_width, win_height, row, col) 56 | local border_opts = { 57 | style = "minimal", 58 | relative = "editor", 59 | width = win_width + 2, 60 | height = win_height + 2, 61 | row = row - 1, 62 | col = col - 1, 63 | } 64 | 65 | local border_lines = { "╔" .. string.rep("═", win_width) .. "╗" } 66 | local middle_line = "║" .. string.rep(" ", win_width) .. "║" 67 | for i = 1, win_height do 68 | table.insert(border_lines, middle_line) 69 | end 70 | table.insert(border_lines, "╚" .. string.rep("═", win_width) .. "╝") 71 | 72 | -- set bufer's (border_buf) lines from first line (0) to last (-1) 73 | -- ignoring out-of-bounds error (false) with lines (border_lines) 74 | local border_buf = vim.api.nvim_create_buf(false, true) 75 | vim.api.nvim_buf_set_lines(border_buf, 0, -1, false, border_lines) 76 | local border_win = vim.api.nvim_open_win(border_buf, true, border_opts) 77 | return border_buf, border_win 78 | end 79 | 80 | M.get_dimensions = function() 81 | -- get dimensions 82 | local width = vim.api.nvim_get_option("columns") 83 | local height = vim.api.nvim_get_option("lines") 84 | 85 | -- calculate our floating window size 86 | local win_height = math.ceil(height * config.options.height - 4) 87 | local win_width = math.ceil(width * config.options.width) 88 | 89 | return { width = width, height = height, win_height = win_height, win_width = win_width } 90 | end 91 | -- TODO: add keybinding legend 92 | -- TODO: Add colorscheme 93 | M.open_window = function(legend_content, dims) 94 | -- and its starting position 95 | local row = math.ceil((dims.height - dims.win_height) / 2 - 1) 96 | local col = math.ceil((dims.width - dims.win_width) / 2) 97 | 98 | local buf = vim.api.nvim_create_buf(false, true) -- create new emtpy buffer 99 | vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe") 100 | 101 | local opts = { 102 | style = "minimal", 103 | relative = "editor", 104 | width = dims.win_width, 105 | height = dims.win_height - vim.fn.len(legend_content), 106 | row = row, 107 | col = col, 108 | } 109 | local legend_buf, _ = open_legend_window(legend_content, dims.win_width, dims.win_height, row, col) 110 | local border_buf, _ = open_border_window(dims.win_width, dims.win_height, row, col) 111 | local win = vim.api.nvim_open_win(buf, true, opts) 112 | vim.api.nvim_command('au BufWipeout exe "silent bwipeout! "' .. border_buf) 113 | vim.api.nvim_command('au BufWipeout exe "silent bwipeout! "' .. legend_buf) 114 | 115 | return buf, win 116 | end 117 | 118 | M.mappings = { 119 | [""] = "open_host()", 120 | j = "move_cursor(-1)", 121 | h = "move_cursor(-1)", 122 | k = "move_cursor(1)", 123 | l = "move_cursor(1)", 124 | q = "close_window()", 125 | d = "disconnect()", 126 | } 127 | M.set_mappings = function(buf) 128 | for k, v in pairs(M.mappings) do 129 | vim.api.nvim_buf_set_keymap(buf, "n", k, ':lua require"sshfs".' .. v .. "", { 130 | nowait = true, 131 | noremap = true, 132 | silent = true, 133 | }) 134 | end 135 | end 136 | 137 | return M 138 | -------------------------------------------------------------------------------- /plugin/commands.vim: -------------------------------------------------------------------------------- 1 | command! -nargs=* SSHFSOpenHosts lua require("sshfs").open_hosts() 2 | command! -nargs=* SSHFSOpenQuickConnect lua require("sshfs").open_quick_connect() 3 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielWeidinger/nvim-sshfs/a988da5115366a4363cfc57c9debf2a90ecb9da4/preview.png --------------------------------------------------------------------------------