├── lua └── sshfs │ ├── integrations │ ├── lf.lua │ ├── oil.lua │ ├── neo_tree.lua │ ├── yazi.lua │ ├── nvim_tree.lua │ ├── nnn.lua │ ├── netrw.lua │ ├── builtin.lua │ ├── ranger.lua │ ├── snacks.lua │ ├── fzf_lua.lua │ ├── mini.lua │ └── telescope.lua │ ├── lib │ ├── directory.lua │ ├── path.lua │ ├── sshfs.lua │ ├── ssh_config.lua │ ├── mount_point.lua │ └── ssh.lua │ ├── ui │ ├── autocommands.lua │ ├── ask.lua │ ├── keymaps.lua │ ├── terminal.lua │ ├── select.lua │ ├── hooks.lua │ └── picker.lua │ ├── init.lua │ ├── session.lua │ ├── config.lua │ ├── health.lua │ └── api.lua ├── LICENSE ├── .gitignore └── README.md /lua/sshfs/integrations/lf.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/lf.lua 2 | -- Lf file manager integration 3 | 4 | local Lf = {} 5 | 6 | --- Attempts to open lf file manager 7 | ---@param cwd string Current working directory to open lf in 8 | ---@return boolean success True if lf was successfully opened 9 | function Lf.explore_files(cwd) 10 | local ok, lf = pcall(require, "lf") 11 | if ok and lf.start then 12 | lf.start(cwd) 13 | return true 14 | end 15 | return false 16 | end 17 | 18 | return Lf 19 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/oil.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/oil.lua 2 | -- Oil.nvim file manager integration 3 | 4 | local Oil = {} 5 | 6 | --- Attempts to open oil.nvim file manager 7 | ---@param cwd string Current working directory to open oil in 8 | ---@return boolean success True if oil was successfully opened 9 | function Oil.explore_files(cwd) 10 | local ok, oil = pcall(require, "oil") 11 | if ok and oil.open then 12 | oil.open(cwd) 13 | return true 14 | end 15 | return false 16 | end 17 | 18 | return Oil 19 | -------------------------------------------------------------------------------- /lua/sshfs/lib/directory.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/lib/directory.lua 2 | 3 | local Directory = {} 4 | 5 | --- Check if a directory is empty 6 | --- @param path string The path to the directory to check 7 | --- @return boolean true if the directory is empty or doesn't exist, false otherwise 8 | function Directory.is_empty(path) 9 | local handle = vim.uv.fs_scandir(path) 10 | if not handle then 11 | return true 12 | end 13 | 14 | local name = vim.uv.fs_scandir_next(handle) 15 | return name == nil 16 | end 17 | 18 | return Directory 19 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/neo_tree.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/neo_tree.lua 2 | -- Neo-tree file explorer integration 3 | 4 | local NeoTree = {} 5 | 6 | --- Attempts to open neo-tree file explorer 7 | ---@param cwd string Current working directory to reveal in neo-tree 8 | ---@return boolean success True if neo-tree was successfully opened 9 | function NeoTree.explore_files(cwd) 10 | local ok = pcall(function() 11 | vim.cmd("Neotree filesystem reveal dir=" .. vim.fn.fnameescape(cwd)) 12 | end) 13 | return ok 14 | end 15 | 16 | return NeoTree 17 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/yazi.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/yazi.lua 2 | -- Yazi file manager integration 3 | 4 | local Yazi = {} 5 | 6 | --- Attempts to open yazi file manager 7 | ---@param cwd string Current working directory to open yazi in 8 | ---@return boolean success True if yazi was successfully opened 9 | function Yazi.explore_files(cwd) 10 | local ok, yazi = pcall(require, "yazi") 11 | if ok and yazi.yazi then 12 | yazi.yazi({ open_for_directories = true }, cwd) 13 | return true 14 | end 15 | return false 16 | end 17 | 18 | return Yazi 19 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/nvim_tree.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/nvim_tree.lua 2 | -- Nvim-tree file explorer integration 3 | 4 | local NvimTree = {} 5 | 6 | --- Attempts to open nvim-tree file explorer 7 | ---@param cwd string Current working directory to open nvim-tree in 8 | ---@return boolean success True if nvim-tree was successfully opened 9 | function NvimTree.explore_files(cwd) 10 | local ok = pcall(function() 11 | vim.cmd("tcd " .. vim.fn.fnameescape(cwd)) 12 | vim.cmd("NvimTreeOpen") 13 | end) 14 | return ok 15 | end 16 | 17 | return NvimTree 18 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/nnn.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/nnn.lua 2 | -- Nnn file manager integration 3 | 4 | local Nnn = {} 5 | 6 | --- Attempts to open nnn file manager 7 | ---@param cwd string Current working directory to open nnn in 8 | ---@return boolean success True if nnn was successfully opened 9 | function Nnn.explore_files(cwd) 10 | local ok, _ = pcall(require, "nnn") 11 | if ok then 12 | -- nnn.nvim uses a command interface 13 | local success = pcall(function() 14 | vim.cmd("NnnPicker " .. vim.fn.fnameescape(cwd)) 15 | end) 16 | return success 17 | end 18 | return false 19 | end 20 | 21 | return Nnn 22 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/netrw.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/netrw.lua 2 | -- Netrw file explorer integration (built-in fallback) 3 | 4 | local config = require("sshfs.config") 5 | 6 | local Netrw = {} 7 | 8 | --- Attempts to open netrw file explorer 9 | ---@param cwd string Current working directory to open netrw in 10 | ---@return boolean success True if netrw was successfully opened 11 | function Netrw.explore_files(cwd) 12 | local ok = pcall(function() 13 | local opts = config.get() 14 | local netrw_cmd = opts.ui.local_picker.netrw_command or "Explore" 15 | vim.cmd(netrw_cmd .. " " .. vim.fn.fnameescape(cwd)) 16 | end) 17 | return ok 18 | end 19 | 20 | return Netrw 21 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/builtin.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/builtin.lua 2 | -- Built-in grep integration (fallback) 3 | 4 | local Builtin = {} 5 | 6 | --- Attempts to use built-in grep via quickfix window 7 | --- Falls back to opening empty quickfix if no pattern provided 8 | ---@param cwd string Current working directory to search in 9 | ---@param pattern? string Optional search pattern to execute 10 | ---@return boolean success True if built-in grep was successfully executed 11 | function Builtin.grep(cwd, pattern) 12 | local ok = pcall(function() 13 | vim.cmd("tcd " .. vim.fn.fnameescape(cwd)) 14 | if pattern and pattern ~= "" then 15 | vim.fn.setreg("/", pattern) 16 | vim.cmd("grep -r " .. vim.fn.shellescape(pattern) .. " .") 17 | else 18 | -- Open empty quickfix window for manual search 19 | vim.cmd("copen") 20 | vim.notify("Ready to search in " .. cwd .. ". Use :grep to search.", vim.log.levels.INFO) 21 | end 22 | end) 23 | return ok 24 | end 25 | 26 | return Builtin 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Robert 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 | -------------------------------------------------------------------------------- /lua/sshfs/lib/path.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/lib/path.lua 2 | -- Path manipulation utilities 3 | 4 | local Path = {} 5 | 6 | --- Map remote file path to local mount path 7 | --- Converts absolute or relative remote paths to paths relative to the search base 8 | ---@param file_path string Remote file path from find/grep command 9 | ---@param remote_base_path string The remote base path that was searched (e.g., "/var/www/app" or ".") 10 | ---@return string relative_path Path relative to the remote base, without leading slashes 11 | function Path.map_remote_to_relative(file_path, remote_base_path) 12 | -- If remote base path is absolute and file starts with it, strip the base path 13 | if remote_base_path ~= "." and file_path:sub(1, #remote_base_path) == remote_base_path then 14 | local relative = file_path:sub(#remote_base_path + 1) 15 | relative = relative:gsub("^/", "") -- Strip leading slash if present 16 | return relative 17 | end 18 | 19 | -- For relative paths (. or relative file paths), strip leading ./ and / 20 | local normalized = file_path:gsub("^%./", "") 21 | normalized = normalized:gsub("^/", "") 22 | return normalized 23 | end 24 | 25 | return Path 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS / System 2 | .DS_Store 3 | Thumbs.db 4 | ehthumbs.db 5 | desktop.ini 6 | *.swp 7 | *.swo 8 | *.bak 9 | *.tmp 10 | *.log 11 | 12 | # IDE / Editors 13 | .vscode/ 14 | .idea/ 15 | *.code-workspace 16 | *.sublime-* 17 | *.iml 18 | 19 | # Logs / Misc 20 | *.env 21 | .env.* 22 | !.env.example 23 | *.local 24 | *.cache/ 25 | .cache/ 26 | *.pid 27 | *.sqlite3 28 | *.db 29 | *.lock 30 | 31 | # Compiled Lua sources 32 | luac.out 33 | 34 | # luarocks build files 35 | *.src.rock 36 | *.zip 37 | *.tar.gz 38 | 39 | # Object files 40 | *.o 41 | *.os 42 | *.ko 43 | *.obj 44 | *.elf 45 | 46 | # Precompiled Headers 47 | *.gch 48 | *.pch 49 | 50 | # Libraries 51 | *.lib 52 | *.a 53 | *.la 54 | *.lo 55 | *.def 56 | *.exp 57 | 58 | # Shared objects (inc. Windows DLLs) 59 | *.dll 60 | *.so 61 | *.so.* 62 | *.dylib 63 | 64 | # Executables 65 | *.exe 66 | *.out 67 | *.app 68 | *.i*86 69 | *.x86_64 70 | *.hex 71 | 72 | # AI 73 | GEMINI.md 74 | CLAUDE.md 75 | QWEN.md 76 | AGENTS.md 77 | *.ai.md 78 | .claude 79 | .gemini 80 | .qwen 81 | *.prompt.md 82 | *.completion.txt 83 | chat_logs/ 84 | agent_memory/ 85 | llm_cache/ 86 | .openrouter/ 87 | .aider/ 88 | .continue/ 89 | .cache/continue/ 90 | codellama/ 91 | promptgen/ 92 | *.llm.* 93 | *.llm-cache 94 | *.llm-out 95 | *.generated.* 96 | .serena 97 | -------------------------------------------------------------------------------- /lua/sshfs/ui/autocommands.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/ui/autocommands.lua 2 | -- UI-related autocommands for sshfs.nvim 3 | 4 | local AutoCommands = {} 5 | 6 | local STATE = { armed = false, base_dir = nil } 7 | 8 | --- Check if path a starts with path b 9 | --- @param a string|nil First path 10 | --- @param b string|nil Second path 11 | --- @return boolean True if a starts with b 12 | local function starts_with(a, b) 13 | local norm = vim.fs.normalize 14 | a, b = norm(a or ""), norm(b or "") 15 | return a == b or vim.startswith(a, b .. "/") 16 | end 17 | 18 | -- Chdir-on-next-open (tab-local, armed once) 19 | -- Changes to tab-local directory when opening file via SSHBrowse 20 | local aug = vim.api.nvim_create_augroup("sshfs_chdir_next_open", { clear = true }) 21 | vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, { 22 | group = aug, 23 | pattern = "*", 24 | callback = function(args) 25 | if not STATE.armed then 26 | return 27 | end 28 | 29 | local is_not_a_file = vim.bo[args.buf].buftype ~= "" 30 | if is_not_a_file then 31 | return 32 | end 33 | 34 | local path = vim.api.nvim_buf_get_name(args.buf) 35 | if path == "" then 36 | return 37 | end 38 | 39 | local is_outside_base_dir = STATE.base_dir and not starts_with(path, STATE.base_dir) 40 | if is_outside_base_dir then 41 | return 42 | end 43 | 44 | vim.cmd("tcd " .. vim.fs.dirname(path)) 45 | 46 | STATE.armed = false 47 | STATE.base_dir = nil 48 | end, 49 | }) 50 | 51 | --- Arm autocommand to change directory on next file open 52 | --- @param base_dir string|nil Base directory to restrict chdir to 53 | function AutoCommands.chdir_on_next_open(base_dir) 54 | STATE.armed = true 55 | STATE.base_dir = base_dir 56 | end 57 | 58 | return AutoCommands 59 | -------------------------------------------------------------------------------- /lua/sshfs/init.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/init.lua 2 | -- Plugin entry point for setup, configuration, and command registration 3 | 4 | local App = {} 5 | 6 | --- Main entry point for plugin initialization. 7 | ---@param user_opts table|nil User configuration options to merge with defaults 8 | function App.setup(user_opts) 9 | local Config = require("sshfs.config") 10 | Config.setup(user_opts) 11 | local opts = Config.get() 12 | 13 | -- Initialize other modules 14 | local MountPoint = require("sshfs.lib.mount_point") 15 | MountPoint.get_or_create() 16 | MountPoint.cleanup_stale() 17 | require("sshfs.ui.keymaps").setup(opts) 18 | 19 | -- Setup exit handler if enabled 20 | local hooks = opts.hooks or {} 21 | local on_exit = hooks.on_exit or {} 22 | if on_exit.auto_unmount then 23 | vim.api.nvim_create_autocmd("VimLeave", { 24 | callback = function() 25 | local Session = require("sshfs.session") 26 | -- Make a copy to avoid modifying table during iteration 27 | local all_connections = vim.list_extend({}, MountPoint.list_active()) 28 | 29 | for _, connection in ipairs(all_connections) do 30 | Session.disconnect_from(connection) 31 | end 32 | end, 33 | desc = "Cleanup SSH mounts on exit", 34 | }) 35 | end 36 | 37 | local Api = require("sshfs.api") 38 | Api.setup() 39 | end 40 | 41 | -- Expose public API methods on App object for require("sshfs").method() usage 42 | local Api = require("sshfs.api") 43 | App.connect = Api.connect 44 | App.mount = Api.mount 45 | App.disconnect = Api.disconnect 46 | App.unmount = Api.unmount 47 | App.unmount_all = Api.unmount_all 48 | App.has_active = Api.has_active 49 | App.get_active = Api.get_active 50 | App.config = Api.config 51 | App.reload = Api.reload 52 | App.files = Api.files 53 | App.grep = Api.grep 54 | App.live_grep = Api.live_grep 55 | App.live_find = Api.live_find 56 | App.explore = Api.explore 57 | App.change_dir = Api.change_dir 58 | App.ssh_terminal = Api.ssh_terminal 59 | App.command = Api.command 60 | 61 | return App 62 | -------------------------------------------------------------------------------- /lua/sshfs/ui/ask.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/ui/ask.lua 2 | -- User choice prompts to the user. Ask: for_mount_path 3 | 4 | local Ask = {} 5 | 6 | --- Normalize remote mount path to handle edge cases 7 | --- @param path string|nil User-provided path 8 | --- @return string Normalized path suitable for remote mounting 9 | local function normalize_remote_path(path) 10 | -- Handle empty/nil -> root directory 11 | if not path or path == "" then 12 | return "/" 13 | end 14 | 15 | -- Trim whitespace 16 | path = vim.trim(path) 17 | 18 | -- Handle empty after trim 19 | if path == "" then 20 | return "/" 21 | end 22 | 23 | -- Pass through ~ paths as-is to resolve during mount 24 | if path == "~" or path:match("^~/") then 25 | return path 26 | end 27 | 28 | -- Handle paths without leading slash -> prepend / 29 | if path:sub(1, 1) ~= "/" then 30 | return "/" .. path 31 | end 32 | 33 | return path 34 | end 35 | 36 | --- Ask for mount location 37 | --- @param host table Host object with name field 38 | --- @param config table Plugin configuration 39 | --- @param callback function Callback invoked with selected remote path or nil 40 | function Ask.for_mount_path(host, config, callback) 41 | local options = { 42 | { label = "Home directory (~)", path = "~" }, 43 | { label = "Root directory (/)", path = "/" }, 44 | { label = "Custom Path", path = nil }, 45 | } 46 | 47 | -- Add global paths that apply to all hosts 48 | local global_paths = config.global_paths 49 | if global_paths and type(global_paths) == "table" then 50 | for _, path in ipairs(global_paths) do 51 | table.insert(options, { label = path, path = path }) 52 | end 53 | end 54 | 55 | -- Add host-specific configured path options (string or array) 56 | local configured_paths = config.host_paths and config.host_paths[host.name] 57 | if configured_paths then 58 | if type(configured_paths) == "string" then 59 | table.insert(options, { label = configured_paths, path = configured_paths }) 60 | elseif type(configured_paths) == "table" then 61 | for _, path in ipairs(configured_paths) do 62 | table.insert(options, { label = path, path = path }) 63 | end 64 | end 65 | end 66 | 67 | vim.ui.select(options, { 68 | prompt = "Select mount location:", 69 | format_item = function(item) 70 | return item.label 71 | end, 72 | }, function(selected) 73 | if not selected then 74 | callback(nil) 75 | return 76 | end 77 | 78 | -- Handle manual path entry 79 | if selected.path == nil then 80 | vim.ui.input({ prompt = "Enter remote path to mount:" }, function(path) 81 | if not path then 82 | callback(nil) 83 | return 84 | end 85 | local normalized_path = normalize_remote_path(path) 86 | callback(normalized_path) 87 | end) 88 | else 89 | local normalized_path = normalize_remote_path(selected.path) 90 | callback(normalized_path) 91 | end 92 | end) 93 | end 94 | 95 | return Ask 96 | -------------------------------------------------------------------------------- /lua/sshfs/ui/keymaps.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/ui/keymaps.lua 2 | -- Keymap configuration and registration for SSH commands with which-key integration support 3 | 4 | local Keymaps = {} 5 | 6 | local DEFAULT_PREFIX = "m" 7 | local DEFAULT_KEYMAPS = { 8 | change_dir = "d", 9 | command = "o", 10 | config = "c", 11 | explore = "e", 12 | files = "f", 13 | grep = "g", 14 | live_find = "F", 15 | live_grep = "G", 16 | mount = "m", 17 | reload = "r", 18 | terminal = "t", 19 | unmount = "u", 20 | unmount_all = "U", 21 | } 22 | 23 | --- Setup keymaps for SSH commands 24 | --- @param opts table|nil Configuration options with keymaps and lead_prefix 25 | function Keymaps.setup(opts) 26 | local user_keymaps = opts and opts.keymaps or {} 27 | local lead_prefix = opts and opts.lead_prefix or DEFAULT_PREFIX 28 | 29 | -- Merge and apply prefix dynamically 30 | local keymaps = {} 31 | for key, suffix in pairs(DEFAULT_KEYMAPS) do 32 | keymaps[key] = user_keymaps[key] or (lead_prefix .. suffix) 33 | end 34 | 35 | -- Set prefix 36 | vim.keymap.set("n", lead_prefix, "", { desc = "mount" }) 37 | 38 | -- Assign keymaps 39 | local Api = require("sshfs.api") 40 | vim.keymap.set("n", keymaps.mount, Api.mount, { desc = "Mount a SSH Server" }) 41 | vim.keymap.set("n", keymaps.unmount, Api.unmount, { desc = "Unmount a SSH Server" }) 42 | vim.keymap.set("n", keymaps.unmount_all, Api.unmount_all, { desc = "Unmount all SSH Servers" }) 43 | vim.keymap.set("n", keymaps.explore, Api.explore, { desc = "Explore SSH mount" }) 44 | vim.keymap.set("n", keymaps.change_dir, Api.change_dir, { desc = "Change dir to mount" }) 45 | vim.keymap.set("n", keymaps.command, Api.command, { desc = "Run command on mount" }) 46 | vim.keymap.set("n", keymaps.config, Api.config, { desc = "Edit SSH config" }) 47 | vim.keymap.set("n", keymaps.reload, Api.reload, { desc = "Reload SSH config" }) 48 | vim.keymap.set("n", keymaps.files, Api.files, { desc = "Find files" }) 49 | vim.keymap.set("n", keymaps.grep, Api.grep, { desc = "Grep files" }) 50 | vim.keymap.set("n", keymaps.live_find, Api.live_find, { desc = "Live find (remote)" }) 51 | vim.keymap.set("n", keymaps.live_grep, Api.live_grep, { desc = "Live grep (remote)" }) 52 | vim.keymap.set("n", keymaps.terminal, Api.ssh_terminal, { desc = "Open SSH Terminal" }) 53 | 54 | -- TODO: Delete after January 15th. 55 | -- Handle deprecated keymap names 56 | if user_keymaps.open_dir then 57 | vim.notify("sshfs.nvim: Keymap 'open_dir' is deprecated. Use 'change_dir' instead.", vim.log.levels.WARN) 58 | end 59 | if user_keymaps.open then 60 | vim.notify("sshfs.nvim: Keymap 'open' is deprecated. Use 'files' instead.", vim.log.levels.WARN) 61 | end 62 | if user_keymaps.edit then 63 | vim.notify("sshfs.nvim: Keymap 'edit' is deprecated. Use 'config' instead.", vim.log.levels.WARN) 64 | end 65 | 66 | -- Check if which-key is installed before registering the group with an icon 67 | local ok, wk = pcall(require, "which-key") 68 | if ok then 69 | wk.add({ 70 | { lead_prefix, icon = { icon = "󰌘", color = "yellow", h1 = "WhichKey" }, group = "mount" }, 71 | }) 72 | end 73 | end 74 | 75 | return Keymaps 76 | -------------------------------------------------------------------------------- /lua/sshfs/ui/terminal.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/ui/terminal.lua 2 | -- Terminal UI components: floating windows and terminal buffers 3 | 4 | local Terminal = {} 5 | 6 | --- Open SSH terminal session to remote host 7 | function Terminal.open_ssh() 8 | local MountPoint = require("sshfs.lib.mount_point") 9 | local Ssh = require("sshfs.lib.ssh") 10 | local active_connections = MountPoint.list_active() 11 | 12 | if #active_connections == 0 then 13 | vim.notify("No active SSH connections", vim.log.levels.WARN) 14 | return 15 | end 16 | 17 | if #active_connections == 1 then 18 | local conn = active_connections[1] 19 | Ssh.open_terminal(conn.host, conn.remote_path) 20 | return 21 | end 22 | 23 | local items = {} 24 | for _, conn in ipairs(active_connections) do 25 | table.insert(items, conn.host) 26 | end 27 | 28 | vim.ui.select(items, { 29 | prompt = "Select host for SSH terminal:", 30 | }, function(_, idx) 31 | if idx then 32 | local conn = active_connections[idx] 33 | Ssh.open_terminal(conn.host, conn.remote_path) 34 | end 35 | end) 36 | end 37 | 38 | --- Open SSH authentication terminal in a floating window 39 | --- Creates a centered floating window and runs the SSH command in a terminal buffer 40 | ---@param cmd table SSH command as array (e.g., {"ssh", "-o", "ControlMaster=yes", "host", "exit"}) 41 | ---@param host string SSH host name (for display in title and notifications) 42 | ---@param callback function Callback(success: boolean, exit_code: number) 43 | function Terminal.open_auth_floating(cmd, host, callback) 44 | -- Create buffer for terminal 45 | local buf = vim.api.nvim_create_buf(false, true) 46 | vim.bo[buf].bufhidden = "wipe" 47 | 48 | -- Calculate floating window dimensions (80% of editor) 49 | local width = math.floor(vim.o.columns * 0.8) 50 | local height = math.floor(vim.o.lines * 0.8) 51 | local row = math.floor((vim.o.lines - height) / 2) 52 | local col = math.floor((vim.o.columns - width) / 2) 53 | 54 | -- Create floating window 55 | local win_opts = { 56 | relative = "editor", 57 | width = width, 58 | height = height, 59 | row = row, 60 | col = col, 61 | style = "minimal", 62 | border = "rounded", 63 | title = " SSH Authentication: " .. host .. " ", 64 | title_pos = "center", 65 | } 66 | local win = vim.api.nvim_open_win(buf, true, win_opts) 67 | 68 | -- Start terminal job with exit callback 69 | local job_id = vim.fn.jobstart(cmd, { 70 | term = true, 71 | on_exit = function(_, exit_code, _) 72 | vim.schedule(function() 73 | local success = exit_code == 0 74 | if success then 75 | -- Auto-close floating window on success 76 | if vim.api.nvim_win_is_valid(win) then 77 | vim.api.nvim_win_close(win, true) 78 | end 79 | else 80 | vim.notify( 81 | string.format("SSH authentication failed for %s (exit code: %d)", host, exit_code), 82 | vim.log.levels.ERROR 83 | ) 84 | end 85 | callback(success, exit_code) 86 | end) 87 | end, 88 | }) 89 | 90 | -- Handle failure to launch 91 | if job_id <= 0 then 92 | vim.notify("Failed to start SSH terminal for " .. host, vim.log.levels.ERROR) 93 | if vim.api.nvim_win_is_valid(win) then 94 | vim.api.nvim_win_close(win, true) 95 | end 96 | callback(false, -1) 97 | return 98 | end 99 | 100 | -- Enter insert mode for user interaction 101 | vim.cmd("startinsert") 102 | end 103 | 104 | return Terminal 105 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/ranger.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/ranger.lua 2 | -- Ranger file manager integration 3 | 4 | local Ranger = {} 5 | 6 | --- Creates a scratch buffer with a path in the target directory 7 | ---@param cwd string Target directory path 8 | ---@return number scratch_buf Buffer handle 9 | local function create_scratch_buffer(cwd) 10 | local scratch_buf = vim.api.nvim_create_buf(false, true) 11 | vim.api.nvim_buf_set_name(scratch_buf, cwd .. "/.ranger_target") 12 | return scratch_buf 13 | end 14 | 15 | --- Restores original buffer in window if showing scratch buffer 16 | ---@param win number Window handle 17 | ---@param orig_buf number Original buffer handle 18 | ---@param scratch_buf number Scratch buffer handle 19 | local function restore_original_buffer(win, orig_buf, scratch_buf) 20 | if vim.api.nvim_win_get_buf(win) == scratch_buf and vim.api.nvim_buf_is_valid(orig_buf) then 21 | vim.api.nvim_win_set_buf(win, orig_buf) 22 | end 23 | end 24 | 25 | --- Cleans up scratch buffer and autocommand group 26 | ---@param scratch_buf number Scratch buffer handle 27 | ---@param augroup number Autocommand group ID 28 | local function cleanup_scratch_resources(scratch_buf, augroup) 29 | vim.defer_fn(function() 30 | if vim.api.nvim_buf_is_valid(scratch_buf) then 31 | pcall(vim.api.nvim_buf_delete, scratch_buf, { force = true }) 32 | end 33 | pcall(vim.api.nvim_del_augroup_by_id, augroup) 34 | end, 100) 35 | end 36 | 37 | --- Sets up autocommands to handle cleanup when ranger closes 38 | ---@param orig_win number Original window handle 39 | ---@param orig_buf number Original buffer handle 40 | ---@param scratch_buf number Scratch buffer handle 41 | ---@return number augroup Autocommand group ID 42 | local function setup_cleanup_autocmds(orig_win, orig_buf, scratch_buf) 43 | local augroup = vim.api.nvim_create_augroup("RangerCleanup_" .. scratch_buf, { clear = true }) 44 | local ranger_opened = false 45 | 46 | -- Detect when ranger terminal opens 47 | vim.api.nvim_create_autocmd("FileType", { 48 | group = augroup, 49 | pattern = "ranger", 50 | once = true, 51 | callback = function() 52 | ranger_opened = true 53 | end, 54 | }) 55 | 56 | -- Detect when we return to the original window after ranger closes 57 | vim.api.nvim_create_autocmd("WinEnter", { 58 | group = augroup, 59 | callback = function() 60 | if ranger_opened and vim.api.nvim_get_current_win() == orig_win then 61 | restore_original_buffer(orig_win, orig_buf, scratch_buf) 62 | cleanup_scratch_resources(scratch_buf, augroup) 63 | end 64 | end, 65 | }) 66 | 67 | return augroup 68 | end 69 | 70 | --- Opens ranger.nvim in the specified directory 71 | ---@param cwd string Directory to open ranger in 72 | ---@return boolean success True if ranger was opened successfully 73 | local function open_ranger_nvim(cwd) 74 | local orig_buf = vim.api.nvim_get_current_buf() 75 | local orig_win = vim.api.nvim_get_current_win() 76 | local scratch_buf = create_scratch_buffer(cwd) 77 | 78 | vim.api.nvim_win_set_buf(orig_win, scratch_buf) -- Switch to scratch buffer so expand("%") picks it up 79 | setup_cleanup_autocmds(orig_win, orig_buf, scratch_buf) 80 | local ok, ranger = pcall(require, "ranger-nvim") 81 | if ok then 82 | ranger.open(true) -- select_current_file= true to use expand("%") 83 | end 84 | 85 | return ok 86 | end 87 | 88 | --- Opens rnvimr in the specified directory 89 | ---@param cwd string Directory to open rnvimr in 90 | ---@return boolean success True if rnvimr was opened successfully 91 | local function open_rnvimr(cwd) 92 | return pcall(function() 93 | vim.cmd("tcd " .. vim.fn.fnameescape(cwd)) 94 | vim.cmd("RnvimrToggle") 95 | end) 96 | end 97 | 98 | --- Attempts to open ranger file manager 99 | --- Tries ranger.nvim first, falls back to rnvimr 100 | ---@param cwd string Current working directory to open ranger in 101 | ---@return boolean success True if ranger was successfully opened 102 | function Ranger.explore_files(cwd) 103 | local ok, ranger = pcall(require, "ranger-nvim") 104 | if ok and ranger.open then 105 | return open_ranger_nvim(cwd) 106 | end 107 | 108 | return open_rnvimr(cwd) 109 | end 110 | 111 | return Ranger 112 | -------------------------------------------------------------------------------- /lua/sshfs/ui/select.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/ui/select.lua 2 | -- User option selections: ssh_config, mount, unmount 3 | 4 | local Select = {} 5 | 6 | --- Get active mounts or display warning. 7 | ---@return table|nil Active mounts array or nil if none found 8 | local function get_active_mounts_or_warn() 9 | local MountPoint = require("sshfs.lib.mount_point") 10 | local active_mounts = MountPoint.list_active() 11 | if not active_mounts or #active_mounts == 0 then 12 | vim.notify("No active SSH mounts found", vim.log.levels.WARN) 13 | return nil 14 | end 15 | return active_mounts 16 | end 17 | 18 | --- SSH config file picker using vim.ui.select. 19 | ---@param callback function Callback invoked with selected config file path 20 | function Select.ssh_config(callback) 21 | local SSHConfig = require("sshfs.lib.ssh_config") 22 | local config_files = SSHConfig.get_default_files() 23 | 24 | -- Filter to only existing files 25 | local available_configs = {} 26 | for _, config in ipairs(config_files) do 27 | if vim.fn.filereadable(config) == 1 then 28 | table.insert(available_configs, config) 29 | end 30 | end 31 | 32 | if #available_configs == 0 then 33 | vim.notify("No readable SSH config files found", vim.log.levels.WARN) 34 | return 35 | end 36 | 37 | vim.ui.select(available_configs, { 38 | prompt = "Select SSH config to edit:", 39 | format_item = function(item) 40 | return vim.fn.fnamemodify(item, ":~") 41 | end, 42 | }, function(choice) 43 | if choice then 44 | callback(choice) 45 | end 46 | end) 47 | end 48 | 49 | --- Mount selection from active mounts. 50 | ---@param callback function Callback invoked with selected mount object 51 | function Select.mount(callback) 52 | local active_mounts = get_active_mounts_or_warn() 53 | if not active_mounts then 54 | return 55 | end 56 | 57 | local mount_list = {} 58 | local mount_map = {} 59 | 60 | for _, mount in ipairs(active_mounts) do 61 | local display = mount.host .. " (" .. mount.mount_path .. ")" 62 | table.insert(mount_list, display) 63 | mount_map[display] = mount 64 | end 65 | 66 | vim.ui.select(mount_list, { 67 | prompt = "Select mount to navigate to:", 68 | format_item = function(item) 69 | return item 70 | end, 71 | }, function(choice) 72 | if choice and mount_map[choice] then 73 | callback(mount_map[choice]) 74 | end 75 | end) 76 | end 77 | 78 | --- Mount selection for unmounting an active mount. 79 | ---@param callback function Callback invoked with selected connection object 80 | function Select.unmount(callback) 81 | local active_mounts = get_active_mounts_or_warn() 82 | if not active_mounts then 83 | return 84 | end 85 | 86 | local mount_list = {} 87 | local mount_map = {} 88 | 89 | for _, mount in ipairs(active_mounts) do 90 | local display = mount.host .. " (" .. mount.mount_path .. ")" 91 | table.insert(mount_list, display) 92 | mount_map[display] = mount 93 | end 94 | 95 | vim.ui.select(mount_list, { 96 | prompt = "Select mount to disconnect:", 97 | format_item = function(item) 98 | return item 99 | end, 100 | }, function(choice) 101 | if choice and mount_map[choice] then 102 | callback(mount_map[choice]) 103 | end 104 | end) 105 | end 106 | 107 | --- Host selection for choosing an SSH Host to connect to. 108 | ---@param callback function Callback invoked with selected host object 109 | function Select.host(callback) 110 | local SSHConfig = require("sshfs.lib.ssh_config") 111 | local hosts = SSHConfig.get_hosts() 112 | 113 | if not hosts or #hosts == 0 then 114 | vim.notify("No SSH hosts found in configuration", vim.log.levels.WARN) 115 | return 116 | end 117 | 118 | -- Sort hosts alphabetically 119 | local host_list = vim.deepcopy(hosts) 120 | table.sort(host_list) 121 | 122 | vim.ui.select(host_list, { 123 | prompt = "Select SSH host to connect:", 124 | format_item = function(item) 125 | return item 126 | end, 127 | }, function(choice) 128 | if choice then 129 | local host, err = SSHConfig.get_host_config(choice) 130 | if not host then 131 | vim.notify("Failed to resolve SSH config: " .. (err or "Unknown error"), vim.log.levels.ERROR) 132 | return 133 | end 134 | callback(host) 135 | end 136 | end) 137 | end 138 | 139 | return Select 140 | -------------------------------------------------------------------------------- /lua/sshfs/ui/hooks.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/ui/hooks.lua 2 | 3 | local Hooks = {} 4 | 5 | -- Normalize hook config value into either a callable or a known preset string. 6 | --- @param action string|function|nil 7 | --- @return string|function|nil normalized 8 | local function normalize_action(action) 9 | if action == nil or action == "none" then 10 | return nil 11 | end 12 | 13 | if type(action) == "function" then 14 | return action 15 | end 16 | 17 | local preset = string.lower(action) 18 | if preset == "livefind" or preset == "live-find" then 19 | preset = "live_find" 20 | elseif preset == "livegrep" or preset == "live-grep" then 21 | preset = "live_grep" 22 | end 23 | 24 | local allowed = { 25 | find = true, 26 | live_find = true, 27 | live_grep = true, 28 | grep = true, 29 | terminal = true, 30 | } 31 | 32 | if not allowed[preset] then 33 | preset = "find" 34 | end 35 | 36 | return preset 37 | end 38 | 39 | -- Fetch the connection associated with a mount directory. 40 | --- @param mount_dir string 41 | --- @return table|nil connection 42 | local function find_connection_by_mount(mount_dir) 43 | local MountPoint = require("sshfs.lib.mount_point") 44 | for _, conn in ipairs(MountPoint.list_active()) do 45 | if conn.mount_path == mount_dir then 46 | return conn 47 | end 48 | end 49 | return nil 50 | end 51 | 52 | -- Execute one of the built-in preset actions. 53 | --- @param preset string 54 | --- @param mount_dir string 55 | --- @param config table 56 | --- @return nil 57 | local function run_preset_action(preset, mount_dir, config) 58 | if preset == "find" then 59 | local Picker = require("sshfs.ui.picker") 60 | local ok, picker_name = Picker.open_file_picker(mount_dir, config) 61 | if not ok then 62 | vim.notify("Failed to open " .. picker_name .. " for new mount: " .. mount_dir, vim.log.levels.ERROR) 63 | end 64 | return 65 | end 66 | 67 | if preset == "grep" then 68 | local Picker = require("sshfs.ui.picker") 69 | Picker.grep_remote_files(nil, { dir = mount_dir }) 70 | return 71 | end 72 | 73 | if preset == "live_grep" or preset == "live_find" then 74 | local conn = find_connection_by_mount(mount_dir) 75 | if not conn then 76 | vim.notify( 77 | "No connection found for on_mount action: " 78 | .. mount_dir 79 | .. " – falling back to local " 80 | .. (preset == "live_grep" and "grep" or "find"), 81 | vim.log.levels.WARN 82 | ) 83 | return run_preset_action(preset == "live_grep" and "grep" or "find", mount_dir, config) 84 | end 85 | local Picker = require("sshfs.ui.picker") 86 | local fn = preset == "live_grep" and Picker.open_live_remote_grep or Picker.open_live_remote_find 87 | local ok, picker_name = fn(conn.host, conn.mount_path, conn.remote_path or ".", config) 88 | if not ok then 89 | vim.notify( 90 | "Live action failed: " 91 | .. picker_name 92 | .. " – falling back to local " 93 | .. (preset == "live_grep" and "grep" or "find"), 94 | vim.log.levels.WARN 95 | ) 96 | return run_preset_action(preset == "live_grep" and "grep" or "find", mount_dir, config) 97 | end 98 | return 99 | end 100 | 101 | if preset == "terminal" then 102 | local Terminal = require("sshfs.ui.terminal") 103 | Terminal.open_ssh() 104 | end 105 | end 106 | 107 | --- Run post-mount action (tcd + optional hook) 108 | --- @param mount_dir string Mount directory path 109 | --- @param host string Host name 110 | --- @param remote_path string|nil Remote path used for the mount 111 | --- @param config table Plugin configuration 112 | function Hooks.on_mount(mount_dir, host, remote_path, config) 113 | local hook_cfg = (config.hooks and config.hooks.on_mount) or {} 114 | local auto_change_to_dir = hook_cfg.auto_change_to_dir 115 | local action = normalize_action(hook_cfg.auto_run) 116 | 117 | -- Auto-change directory to mount point if configured 118 | if auto_change_to_dir then 119 | vim.cmd("tcd " .. vim.fn.fnameescape(mount_dir)) 120 | end 121 | 122 | if action == nil then 123 | return 124 | end 125 | 126 | -- Allow custom handler 127 | if type(action) == "function" then 128 | local ok, err = pcall(action, { 129 | mount_path = mount_dir, 130 | host = host, 131 | remote_path = remote_path, 132 | }) 133 | if not ok then 134 | vim.notify("on_mount callback failed: " .. err, vim.log.levels.ERROR) 135 | end 136 | return 137 | end 138 | 139 | run_preset_action(action, mount_dir, config) 140 | end 141 | 142 | return Hooks 143 | -------------------------------------------------------------------------------- /lua/sshfs/session.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/session.lua 2 | -- SSH session lifecycle management: setup, connect, disconnect, reload 3 | 4 | local Session = {} 5 | local Config = require("sshfs.config") 6 | local PRE_MOUNT_DIRS = {} -- Track pre-mount directory for each connection 7 | 8 | --- Connect to a remote SSH host via SSHFS 9 | ---@param host table Host object with name, user, port, and path fields 10 | ---@return boolean|nil Success status (or nil if async callback) 11 | function Session.connect(host) 12 | local MountPoint = require("sshfs.lib.mount_point") 13 | local config = Config.get() 14 | local mount_dir = config.mounts.base_dir .. "/" .. host.name 15 | 16 | -- Check if already mounted 17 | if MountPoint.is_active(mount_dir) then 18 | vim.notify("Host " .. host.name .. " is already mounted at " .. mount_dir, vim.log.levels.WARN) 19 | return true 20 | end 21 | 22 | -- Capture current directory before mounting for restoration on disconnect 23 | PRE_MOUNT_DIRS[mount_dir] = vim.uv.cwd() 24 | 25 | -- Ensure mount directory exists 26 | if not MountPoint.get_or_create(mount_dir) then 27 | vim.notify("Failed to create mount directory: " .. mount_dir, vim.log.levels.ERROR) 28 | return false 29 | end 30 | 31 | -- Ask the user for the mount path 32 | local Ask = require("sshfs.ui.ask") 33 | Ask.for_mount_path(host, config, function(remote_path_suffix) 34 | if not remote_path_suffix then 35 | vim.notify("Connection cancelled.", vim.log.levels.WARN) 36 | MountPoint.cleanup() 37 | return 38 | end 39 | 40 | -- Attempt authentication and mounting (async) 41 | local Sshfs = require("sshfs.lib.sshfs") 42 | Sshfs.authenticate_and_mount(host, mount_dir, remote_path_suffix, function(result) 43 | -- Handle connection failure 44 | if not result.success then 45 | vim.notify("Connection failed: " .. (result.message or "Unknown error"), vim.log.levels.ERROR) 46 | MountPoint.cleanup() 47 | return 48 | end 49 | 50 | -- Navigate to remote directory with picker 51 | -- Use resolved_path (tilde-expanded) for accurate path mapping in live actions 52 | vim.notify("Connected to " .. host.name, vim.log.levels.INFO) 53 | local Hooks = require("sshfs.ui.hooks") 54 | local final_path = result.resolved_path or remote_path_suffix 55 | Hooks.on_mount(mount_dir, host.name, final_path, config) 56 | end) 57 | end) 58 | end 59 | 60 | --- Disconnect from the currently active SSH mount 61 | ---@return boolean Success status 62 | function Session.disconnect() 63 | local MountPoint = require("sshfs.lib.mount_point") 64 | local active_connection = MountPoint.get_active() 65 | if not active_connection or not active_connection.mount_path then 66 | vim.notify("No active connection to disconnect", vim.log.levels.WARN) 67 | return false 68 | end 69 | 70 | return Session.disconnect_from(active_connection) 71 | end 72 | 73 | --- Disconnect from a specific SSH connection 74 | ---@param connection table Connection object with host and mount_path fields 75 | ---@param silent boolean|nil If true, suppress notifications (optional, defaults to false) 76 | ---@return boolean Success status 77 | function Session.disconnect_from(connection, silent) 78 | local MountPoint = require("sshfs.lib.mount_point") 79 | if not connection or not connection.mount_path then 80 | vim.notify("Invalid connection to disconnect", vim.log.levels.WARN) 81 | return false 82 | end 83 | 84 | -- Change directory if currently inside mount point 85 | local cwd = vim.uv.cwd() 86 | if cwd and connection.mount_path and cwd:find(connection.mount_path, 1, true) == 1 then 87 | local restore_dir = PRE_MOUNT_DIRS[connection.mount_path] 88 | if restore_dir and vim.fn.isdirectory(restore_dir) == 1 then 89 | vim.cmd("tcd " .. vim.fn.fnameescape(restore_dir)) 90 | else 91 | vim.cmd("tcd " .. vim.fn.expand("~")) 92 | end 93 | end 94 | 95 | -- Unmount the filesystem 96 | local success = MountPoint.unmount(connection.mount_path) 97 | 98 | -- Cleanup 99 | if success then 100 | if not silent then 101 | vim.notify("Disconnected from " .. connection.host, vim.log.levels.INFO) 102 | end 103 | 104 | -- Clean up ControlMaster socket 105 | local Ssh = require("sshfs.lib.ssh") 106 | Ssh.cleanup_control_master(connection.host) 107 | 108 | -- Remove pre-mount cache and mount point 109 | PRE_MOUNT_DIRS[connection.mount_path] = nil 110 | local config = Config.get() 111 | if config.hooks and config.hooks.on_exit and config.hooks.on_exit.clean_mount_folders then 112 | MountPoint.cleanup() 113 | end 114 | 115 | return true 116 | else 117 | if not silent then 118 | vim.notify("Failed to disconnect from " .. connection.host, vim.log.levels.ERROR) 119 | end 120 | return false 121 | end 122 | end 123 | 124 | --- Reload SSH configuration by clearing the cache 125 | function Session.reload() 126 | local SSHConfig = require("sshfs.lib.ssh_config") 127 | SSHConfig.refresh() 128 | vim.notify("SSH configuration reloaded", vim.log.levels.INFO) 129 | end 130 | 131 | return Session 132 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/snacks.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/snacks.lua 2 | -- Snacks.nvim picker and search integration 3 | 4 | local Snacks = {} 5 | 6 | --- Attempts to open snacks.nvim file picker 7 | ---@param cwd string Current working directory to open picker in 8 | ---@return boolean success True if snacks picker was successfully opened 9 | function Snacks.explore_files(cwd) 10 | local ok, snacks = pcall(require, "snacks") 11 | if ok and snacks.picker and snacks.picker.files then 12 | snacks.picker.files({ cwd = cwd }) 13 | return true 14 | end 15 | return false 16 | end 17 | 18 | --- Attempts to open snacks.nvim grep search 19 | ---@param cwd string Current working directory to search in 20 | ---@param pattern? string Optional search pattern to pre-populate 21 | ---@return boolean success True if snacks grep was successfully opened 22 | function Snacks.grep(cwd, pattern) 23 | local ok, snacks = pcall(require, "snacks") 24 | if ok and snacks.picker and snacks.picker.grep then 25 | local opts = { cwd = cwd } 26 | if pattern and pattern ~= "" then 27 | opts.search = pattern 28 | end 29 | snacks.picker.grep(opts) 30 | return true 31 | end 32 | return false 33 | end 34 | 35 | --- Attempts to open snacks.nvim live grep with remote SSH execution 36 | ---@param host string SSH host name 37 | ---@param mount_path string Local mount path to map remote files 38 | ---@param path? string Optional remote path to search (defaults to home) 39 | ---@param callback? function Optional callback(success: boolean) 40 | function Snacks.live_grep(host, mount_path, path, callback) 41 | local ok, snacks = pcall(require, "snacks") 42 | if not ok or not snacks.picker then 43 | if callback then 44 | callback(false) 45 | end 46 | return 47 | end 48 | 49 | local Ssh = require("sshfs.lib.ssh") 50 | local remote_path = path or "." 51 | 52 | -- Build SSH command 53 | local ssh_cmd = Ssh.build_command(host) 54 | local grep_cmd = string.format( 55 | "rg --color=never --no-heading --with-filename --line-number --column --smart-case -- {q} %s 2>/dev/null || grep -r -n -H -- {q} %s", 56 | remote_path, 57 | remote_path 58 | ) 59 | table.insert(ssh_cmd, grep_cmd) 60 | 61 | -- Custom picker with SSH grep 62 | snacks.picker.pick({ 63 | prompt = "Remote Grep (" .. host .. ")", 64 | live = true, 65 | finder = function(_, ctx) 66 | local search = ctx.filter.search 67 | if not search or search == "" then 68 | return function() end 69 | end 70 | 71 | -- Replace {q} placeholder with actual search term 72 | local final_cmd = grep_cmd:gsub("{q}", vim.fn.shellescape(search)) 73 | local ssh_args = vim.list_slice(ssh_cmd, 2) 74 | ssh_args[#ssh_args] = final_cmd 75 | 76 | local proc = require("snacks.picker.source.proc") 77 | return proc.proc({ 78 | cmd = ssh_cmd[1], 79 | args = ssh_args, 80 | notify = false, 81 | transform = function(item) 82 | -- Parse grep output: filename:line:column:text 83 | local filename, lnum, col, text = item.text:match("^([^:]+):(%d+):(%d+):(.*)$") 84 | if not filename then 85 | filename, lnum, text = item.text:match("^([^:]+):(%d+):(.*)$") 86 | col = "1" 87 | end 88 | 89 | if not filename or not lnum then 90 | return false 91 | end 92 | 93 | local Path = require("sshfs.lib.path") 94 | local relative_file = Path.map_remote_to_relative(filename, remote_path) 95 | local local_file = mount_path .. "/" .. relative_file 96 | 97 | item.file = local_file 98 | item.pos = { tonumber(lnum), tonumber(col) - 1 } 99 | item.text = filename .. ":" .. lnum .. ":" .. (text or "") 100 | end, 101 | }, ctx) 102 | end, 103 | }) 104 | 105 | if callback then 106 | callback(true) 107 | end 108 | end 109 | 110 | --- Attempts to open snacks.nvim live find with remote SSH execution 111 | ---@param host string SSH host name 112 | ---@param mount_path string Local mount path to map remote files 113 | ---@param path? string Optional remote path to search (defaults to home) 114 | ---@param callback? function Optional callback(success: boolean) 115 | function Snacks.live_find(host, mount_path, path, callback) 116 | local ok, snacks = pcall(require, "snacks") 117 | if not ok or not snacks.picker then 118 | if callback then 119 | callback(false) 120 | end 121 | return 122 | end 123 | 124 | local Ssh = require("sshfs.lib.ssh") 125 | local remote_path = path or "." 126 | 127 | -- Build SSH find command 128 | local ssh_cmd = Ssh.build_command(host) 129 | local find_cmd = 130 | string.format("fd --color=never --type=f . %s 2>/dev/null || find %s -type f", remote_path, remote_path) 131 | table.insert(ssh_cmd, find_cmd) 132 | 133 | local proc = require("snacks.picker.source.proc") 134 | 135 | -- Custom picker with SSH find 136 | snacks.picker.pick({ 137 | prompt = "Remote Find (" .. host .. ")", 138 | finder = function(_, ctx) 139 | return proc.proc({ 140 | cmd = ssh_cmd[1], 141 | args = vim.list_slice(ssh_cmd, 2), 142 | notify = false, 143 | transform = function(item) 144 | local filename = item.text 145 | 146 | if not filename or filename == "" then 147 | return false 148 | end 149 | 150 | local Path = require("sshfs.lib.path") 151 | local relative_file = Path.map_remote_to_relative(filename, remote_path) 152 | local local_file = mount_path .. "/" .. relative_file 153 | 154 | item.file = local_file 155 | item.text = filename 156 | end, 157 | }, ctx) 158 | end, 159 | }) 160 | 161 | if callback then 162 | callback(true) 163 | end 164 | end 165 | 166 | return Snacks 167 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/fzf_lua.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/fzf_lua.lua 2 | -- Fzf-lua file picker and search integration 3 | 4 | local FzfLua = {} 5 | 6 | --- Attempts to open fzf-lua file picker 7 | ---@param cwd string Current working directory to open picker in 8 | ---@return boolean success True if fzf-lua was successfully opened 9 | function FzfLua.explore_files(cwd) 10 | local ok, fzf = pcall(require, "fzf-lua") 11 | if ok and fzf.files then 12 | fzf.files({ cwd = cwd, previewer = "builtin" }) 13 | return true 14 | end 15 | return false 16 | end 17 | 18 | --- Attempts to open fzf-lua live grep search 19 | ---@param cwd string Current working directory to search in 20 | ---@param pattern? string Optional search pattern to pre-populate 21 | ---@return boolean success True if fzf-lua was successfully opened 22 | function FzfLua.grep(cwd, pattern) 23 | local ok, fzf = pcall(require, "fzf-lua") 24 | if ok and fzf.live_grep then 25 | local opts = { cwd = cwd, previewer = "builtin" } 26 | if pattern and pattern ~= "" then 27 | opts.query = pattern 28 | end 29 | fzf.live_grep(opts) 30 | return true 31 | end 32 | return false 33 | end 34 | 35 | --- Attempts to open fzf-lua live grep with remote SSH execution 36 | ---@param host string SSH host name 37 | ---@param mount_path string Local mount path to map remote files 38 | ---@param path? string Optional remote path to search (defaults to home) 39 | ---@param callback? function Optional callback(success: boolean) 40 | function FzfLua.live_grep(host, mount_path, path, callback) 41 | local ok, fzf = pcall(require, "fzf-lua") 42 | if not ok or not fzf.fzf_live then 43 | if callback then 44 | callback(false) 45 | end 46 | return 47 | end 48 | 49 | -- Build grep command string. fzf_live will replace {q} with user input. 50 | local Ssh = require("sshfs.lib.ssh") 51 | local ssh_cmd = Ssh.build_command(host) 52 | local ssh_base = table.concat(ssh_cmd, " ") 53 | local remote_path = path or "." 54 | local rg_cmd = string.format( 55 | '%s "rg --color=never --no-heading --with-filename --line-number --column --smart-case -- {q} %s || true"', 56 | ssh_base, 57 | remote_path 58 | ) 59 | 60 | -- Configure fzf-lua live grep with preview (10000 line limit for large files) 61 | local preview_cmd = string.format( 62 | [[%s "bat --color=always --style=numbers --highlight-line={2} --line-range=:10000 {1} 2>/dev/null || head -n 10000 {1}" 2>/dev/null || echo "Preview unavailable"]], 63 | ssh_base 64 | ) 65 | 66 | local opts = { 67 | prompt = "Remote Grep (" .. host .. ")> ", 68 | cmd = rg_cmd, 69 | preview = preview_cmd, 70 | fzf_opts = { 71 | ["--delimiter"] = ":", 72 | ["--preview-window"] = "right:50%:+{2}-/2", 73 | }, 74 | actions = { 75 | ["default"] = function(selected) 76 | if not selected or #selected == 0 then 77 | return 78 | end 79 | 80 | -- Parse ripgrep output: filename:line:column:text 81 | local entry = selected[1] 82 | local file, line, col = entry:match("^([^:]+):(%d+):(%d+):") 83 | if not file then 84 | file, line = entry:match("^([^:]+):(%d+):") 85 | col = 1 86 | end 87 | 88 | -- Open file 89 | if file and line then 90 | local Path = require("sshfs.lib.path") 91 | local relative_file = Path.map_remote_to_relative(file, remote_path) 92 | local local_file = mount_path .. "/" .. relative_file 93 | vim.notify("Opening file...", vim.log.levels.INFO) 94 | vim.cmd("edit +" .. line .. " " .. vim.fn.fnameescape(local_file)) 95 | if col then 96 | vim.api.nvim_win_set_cursor(0, { tonumber(line), tonumber(col) - 1 }) 97 | end 98 | end 99 | end, 100 | }, 101 | } 102 | 103 | fzf.fzf_live(rg_cmd, opts) 104 | 105 | if callback then 106 | callback(true) 107 | end 108 | end 109 | 110 | --- Attempts to open fzf-lua live find with remote SSH execution 111 | ---@param host string SSH host name 112 | ---@param mount_path string Local mount path to map remote files 113 | ---@param path? string Optional remote path to search (defaults to home) 114 | ---@param callback? function Optional callback(success: boolean) 115 | function FzfLua.live_find(host, mount_path, path, callback) 116 | local ok, fzf = pcall(require, "fzf-lua") 117 | if not ok or not fzf.fzf_exec then 118 | if callback then 119 | callback(false) 120 | end 121 | return 122 | end 123 | 124 | -- Build remote find command. Try fd first, fallback to find 125 | local Ssh = require("sshfs.lib.ssh") 126 | local ssh_cmd = Ssh.build_command(host) 127 | local ssh_base = table.concat(ssh_cmd, " ") 128 | local remote_path = path or "." 129 | local find_cmd = string.format( 130 | '%s "fd --color=never --type=f . %s 2>/dev/null || find %s -type f"', 131 | ssh_base, 132 | remote_path, 133 | remote_path 134 | ) 135 | 136 | -- Configure fzf-lua with custom command and preview (10000 line limit for large files) 137 | local preview_cmd = string.format( 138 | [[%s "bat --color=always --style=numbers --line-range=:10000 {} 2>/dev/null || head -n 10000 {}" 2>/dev/null || echo "Preview unavailable"]], 139 | ssh_base 140 | ) 141 | 142 | local opts = { 143 | prompt = "Remote Find (" .. host .. ")> ", 144 | preview = preview_cmd, 145 | actions = { 146 | ["default"] = function(selected) 147 | if not selected or #selected == 0 then 148 | return 149 | end 150 | 151 | -- Open file 152 | local file = selected[1] 153 | local Path = require("sshfs.lib.path") 154 | local relative_file = Path.map_remote_to_relative(file, remote_path) 155 | local local_file = mount_path .. "/" .. relative_file 156 | vim.notify("Opening file...", vim.log.levels.INFO) 157 | vim.cmd("edit " .. vim.fn.fnameescape(local_file)) 158 | end, 159 | }, 160 | } 161 | 162 | fzf.fzf_exec(find_cmd, opts) 163 | 164 | if callback then 165 | callback(true) 166 | end 167 | end 168 | 169 | return FzfLua 170 | -------------------------------------------------------------------------------- /lua/sshfs/lib/sshfs.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/lib/sshfs.lua 2 | -- SSHFS wrapper with authentication workflows 3 | 4 | local Sshfs = {} 5 | 6 | --- Convert sshfs_options table to array format for sshfs -o 7 | --- @param options_table table Table of options (e.g., {reconnect = true, ConnectTimeout = 5}) 8 | --- @return table Array of option strings (e.g., {"reconnect", "ConnectTimeout=5"}) 9 | local function build_sshfs_args(options_table) 10 | local result = {} 11 | 12 | for key, value in pairs(options_table) do 13 | if value == true then 14 | -- Boolean true: just add the key 15 | table.insert(result, key) 16 | elseif value ~= false and value ~= nil then 17 | -- String or number: add as key=value 18 | table.insert(result, string.format("%s=%s", key, tostring(value))) 19 | end 20 | -- false or nil: skip this option 21 | end 22 | 23 | return result 24 | end 25 | 26 | --- Build sshfs options array for mounting via established ControlMaster socket 27 | --- @param auth_type string Authentication type (only "socket" is used in SSH-first flow) 28 | --- @return table Array of sshfs options 29 | local function get_sshfs_options(auth_type) 30 | local Config = require("sshfs.config") 31 | local Ssh = require("sshfs.lib.ssh") 32 | local opts = Config.get() 33 | local options = {} 34 | 35 | -- Add user-configured sshfs options from config (convert table to array) 36 | if opts.connections and opts.connections.sshfs_options then 37 | local sshfs_opts = build_sshfs_args(opts.connections.sshfs_options) 38 | vim.list_extend(options, sshfs_opts) 39 | end 40 | 41 | -- Add SSH command to reuse existing ControlMaster socket 42 | if auth_type == "socket" then 43 | local ssh_cmd = Ssh.build_command_string("socket") 44 | if ssh_cmd ~= "ssh" then 45 | table.insert(options, "ssh_command=" .. ssh_cmd) 46 | end 47 | end 48 | 49 | return options 50 | end 51 | 52 | --- Execute the actual mount command (private helper) 53 | --- @param host table Host object with name, user, port, and path fields 54 | --- @param mount_point string Local mount point directory 55 | --- @param remote_path_suffix string Remote path to mount (already resolved) 56 | --- @param callback function Callback function(result: table) - result has fields: success, message, resolved_path 57 | local function mount_with_path(host, mount_point, remote_path_suffix, callback) 58 | local options = get_sshfs_options("socket") 59 | 60 | -- Use host.name (the alias) to let SSH config resolution work properly 61 | local remote_path = host.name 62 | if host.user then 63 | remote_path = host.user .. "@" .. remote_path 64 | end 65 | remote_path = remote_path .. ":" .. remote_path_suffix 66 | 67 | -- Add options/port 68 | local cmd = { "sshfs", remote_path, mount_point, "-o", table.concat(options, ",") } 69 | if host.port then 70 | table.insert(cmd, "-p") 71 | table.insert(cmd, host.port) 72 | end 73 | 74 | -- Execute mount command asynchronously 75 | vim.system(cmd, { text = true }, function(obj) 76 | vim.schedule(function() 77 | if obj.code == 0 then 78 | callback({ 79 | success = true, 80 | message = "Mount successful", 81 | resolved_path = remote_path_suffix, 82 | }) 83 | else 84 | local error_msg = obj.stderr or obj.stdout or "Unknown error" 85 | callback({ 86 | success = false, 87 | message = "Mount failed: " .. error_msg, 88 | }) 89 | end 90 | end) 91 | end) 92 | end 93 | 94 | --- Mount via established ControlMaster socket (async, private helper) 95 | --- Assumes SSH connection is already authenticated and socket exists 96 | --- @param host table Host object with name, user, port, and path fields 97 | --- @param mount_point string Local mount point directory 98 | --- @param remote_path_suffix string|nil Remote path to mount 99 | --- @param callback function Callback function(result: table) - result has fields: success, message, resolved_path 100 | local function mount_via_socket(host, mount_point, remote_path_suffix, callback) 101 | remote_path_suffix = remote_path_suffix or (host.path or "") 102 | 103 | -- If path starts with ~, resolve it to the actual home directory 104 | -- This handles symlinked home directories and non-standard structures 105 | if remote_path_suffix:match("^~") then 106 | local Ssh = require("sshfs.lib.ssh") 107 | Ssh.get_remote_home(host.name, function(actual_home, error) 108 | if actual_home then 109 | -- Replace ~ with the actual home path and mount 110 | local resolved_path = remote_path_suffix:gsub("^~", actual_home) 111 | mount_with_path(host, mount_point, resolved_path, callback) 112 | else 113 | -- Fall back to letting SSHFS try to handle it (may fail for symlinks) 114 | vim.notify( 115 | "Could not resolve home directory, attempting mount anyway: " .. (error or "unknown error"), 116 | vim.log.levels.WARN 117 | ) 118 | mount_with_path(host, mount_point, remote_path_suffix, callback) 119 | end 120 | end) 121 | return 122 | end 123 | 124 | mount_with_path(host, mount_point, remote_path_suffix, callback) 125 | end 126 | 127 | --- Authenticate and mount using SSH-first (async) 128 | --- @param host table Host object with name, user, port, and path fields 129 | --- @param mount_point string Local mount point directory 130 | --- @param remote_path_suffix string|nil Remote path to mount 131 | --- @param callback function Callback function(result: table) - result has fields: success, message, resolved_path 132 | function Sshfs.authenticate_and_mount(host, mount_point, remote_path_suffix, callback) 133 | local Ssh = require("sshfs.lib.ssh") 134 | vim.notify("Connecting to " .. host.name .. "...", vim.log.levels.INFO) 135 | 136 | -- Try batch connection (non-interactive) 137 | Ssh.try_batch_connect(host.name, function(success, exit_code, error) 138 | if success then 139 | mount_via_socket(host, mount_point, remote_path_suffix, callback) 140 | return 141 | end 142 | 143 | -- Batch failed, try interactive terminal 144 | Ssh.open_auth_terminal(host.name, function(term_success, term_exit_code) 145 | if term_success then 146 | mount_via_socket(host, mount_point, remote_path_suffix, callback) 147 | else 148 | callback({ 149 | success = false, 150 | message = string.format( 151 | "SSH authentication failed for %s (exit code: %d)", 152 | host.name, 153 | term_exit_code 154 | ), 155 | }) 156 | end 157 | end) 158 | end) 159 | end 160 | 161 | return Sshfs 162 | -------------------------------------------------------------------------------- /lua/sshfs/lib/ssh_config.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/lib/ssh_config.lua 2 | -- SSH configuration parsing using 'ssh -G' for proper config resolution 3 | -- Handles Include, Match, ProxyJump, and all SSH config features 4 | 5 | local SSHConfig = {} 6 | 7 | -- Cache state for parsed ssh config files 8 | local CACHE = { 9 | hosts = nil, 10 | last_modified = 0, 11 | resolved_configs = {}, -- Cache for ssh -G results 12 | } 13 | 14 | --- Get the most recent modification time from a list of files 15 | ---@param files table List of file paths to check 16 | ---@return number The latest modification timestamp (seconds since epoch) 17 | local function get_last_modified(files) 18 | local modified_time = 0 19 | for _, file in ipairs(files) do 20 | local stat = vim.uv.fs_stat(vim.fn.expand(file)) 21 | if stat and stat.mtime.sec > modified_time then 22 | modified_time = stat.mtime.sec 23 | end 24 | end 25 | return modified_time 26 | end 27 | 28 | --- Parse SSH config files to extract Host entries (aliases only) 29 | --- This provides the list of available hosts for selection 30 | --- @param config_files table List of SSH config file paths to parse 31 | --- @return table List of host aliases (strings) 32 | local function parse_host_aliases(config_files) 33 | local hosts = {} 34 | local seen = {} 35 | 36 | for _, path in ipairs(config_files) do 37 | local expanded_path = vim.fn.expand(path) 38 | if vim.fn.filereadable(expanded_path) == 1 then 39 | for line in io.lines(expanded_path) do 40 | -- Skip comments and empty lines 41 | if line:sub(1, 1) ~= "#" and line:match("%S") then 42 | local host_names = line:match("^%s*Host%s+(.+)$") 43 | if host_names then 44 | -- Extract all host aliases from this Host line 45 | for host_name in host_names:gmatch("%S+") do 46 | -- Skip wildcards: *, ?, and patterns containing * or ? 47 | if not host_name:match("[*?]") and not seen[host_name] then 48 | table.insert(hosts, host_name) 49 | seen[host_name] = true 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | 58 | return hosts 59 | end 60 | 61 | --- Execute 'ssh -G hostname' to get fully resolved SSH configuration 62 | --- This handles Include, Match, ProxyJump, HostName resolution, and all SSH features 63 | --- @param hostname string The host alias or hostname to resolve 64 | --- @return table|nil Resolved configuration as key-value table, or nil on error 65 | --- @return string|nil Error message if resolution failed 66 | local function resolve_host_config(hostname) 67 | local cmd = { "ssh", "-G", hostname } 68 | local output = vim.fn.system(cmd) 69 | 70 | if vim.v.shell_error ~= 0 then 71 | return nil, string.format("Failed to resolve SSH config for '%s': %s", hostname, output) 72 | end 73 | 74 | -- Parse the output (format: "key value" per line) 75 | local config = {} 76 | for line in output:gmatch("[^\r\n]+") do 77 | local key, value = line:match("^(%S+)%s+(.+)$") 78 | if key and value then 79 | local normalized_key = key:lower() 80 | -- Handle multiple values (e.g., multiple IdentityFile entries) 81 | if config[normalized_key] then 82 | if type(config[normalized_key]) ~= "table" then 83 | config[normalized_key] = { config[normalized_key] } 84 | end 85 | table.insert(config[normalized_key], value) 86 | else 87 | config[normalized_key] = value 88 | end 89 | end 90 | end 91 | 92 | return config, nil 93 | end 94 | 95 | --- Get default SSH config file paths 96 | --- @return table List of default SSH config file paths to check 97 | function SSHConfig.get_default_files() 98 | return { 99 | vim.fn.expand("$HOME") .. "/.ssh/config", 100 | "/etc/ssh/ssh_config", 101 | } 102 | end 103 | 104 | --- Get all SSH host aliases from configured SSH config files 105 | --- Returns only the host aliases (names) without resolved configuration 106 | --- Use get_host_config() to get the full resolved configuration for a specific host 107 | --- Automatically caches results and invalidates cache when config files are modified 108 | --- @return table Array of host alias strings 109 | function SSHConfig.get_hosts() 110 | local Config = require("sshfs.config") 111 | local opts = Config.get() 112 | local config_files = opts.connections.ssh_configs 113 | 114 | -- Return cached hosts if files haven't changed 115 | local modified_time = get_last_modified(config_files) 116 | local is_config_same = CACHE.hosts and CACHE.last_modified == modified_time 117 | if is_config_same then 118 | return CACHE.hosts 119 | end 120 | 121 | -- File has changes, parse config files to get host aliases 122 | local hosts = parse_host_aliases(config_files) 123 | CACHE.hosts = hosts 124 | CACHE.last_modified = modified_time 125 | CACHE.resolved_configs = {} 126 | return hosts 127 | end 128 | 129 | --- Get fully resolved SSH configuration for a specific host using 'ssh -G' 130 | --- This provides the complete configuration that SSH will actually use. 131 | --- @param hostname string The host alias to resolve 132 | --- @return table|nil Host configuration with fields like: name, hostname, user, port, identityfile, proxyjump, etc. 133 | --- @return string|nil Error message if resolution failed 134 | function SSHConfig.get_host_config(hostname) 135 | if CACHE.resolved_configs[hostname] then 136 | return CACHE.resolved_configs[hostname], nil 137 | end 138 | 139 | -- Resolve using ssh -G 140 | local config, err = resolve_host_config(hostname) 141 | if not config then 142 | return nil, err 143 | end 144 | 145 | -- Build host object with commonly used fields 146 | -- Note: 'name' is the alias, 'hostname' is the resolved target address 147 | local host = { 148 | name = hostname, 149 | hostname = config.hostname or hostname, 150 | user = config.user, 151 | port = config.port, 152 | identityfile = config.identityfile, -- May be table if multiple 153 | proxyjump = config.proxyjump, 154 | proxycommand = config.proxycommand, 155 | -- Store full config for advanced use cases 156 | _raw_config = config, 157 | } 158 | 159 | CACHE.resolved_configs[hostname] = host 160 | 161 | return host, nil 162 | end 163 | 164 | --- Parse a host connection string into a host object 165 | --- Used for parsing command-line arguments like: `:SSHConnect user@host:path -p 2222` 166 | --- @param command string SSH command string (e.g., "user@host:path" or "host -p 2222") 167 | --- @return table Host object with name, user, path, and port fields 168 | function SSHConfig.parse_host(command) 169 | local host = {} 170 | 171 | -- Get port and remove port from command 172 | local port = command:match("%-p (%d+)") 173 | host.port = port 174 | command = command:gsub("%s*%-p %d+%s*", "") 175 | 176 | -- Parse user@hostname:path format 177 | local user, hostname, path = command:match("^([^@]+)@([^:]+):?(.*)$") 178 | if not user then 179 | hostname, path = command:match("^([^:]+):?(.*)$") 180 | end 181 | 182 | host.name = hostname 183 | host.hostname = hostname -- For CLI parsing, name and hostname are the same 184 | host.user = user 185 | host.path = path ~= "" and path or nil 186 | 187 | return host 188 | end 189 | 190 | --- Clear the hosts and resolved_configs caches to force re-parsing on next get_hosts() call 191 | function SSHConfig.refresh() 192 | CACHE.hosts = nil 193 | CACHE.last_modified = 0 194 | CACHE.resolved_configs = {} 195 | end 196 | 197 | return SSHConfig 198 | -------------------------------------------------------------------------------- /lua/sshfs/config.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/config.lua 2 | 3 | local Config = {} 4 | 5 | -- stylua: ignore start 6 | local DEFAULT_CONFIG = { 7 | connections = { 8 | ssh_configs = require("sshfs.lib.ssh_config").get_default_files(), 9 | -- SSHFS mount options (table of key-value pairs converted to sshfs -o arguments) 10 | -- Boolean flags: set to true to include, false/nil to omit 11 | -- String/number values: converted to key=value format 12 | sshfs_options = { 13 | reconnect = true, -- Auto-reconnect on connection loss 14 | ConnectTimeout = 5, -- Connection timeout in seconds 15 | compression = "yes", -- Enable compression 16 | ServerAliveInterval = 15, -- Keep-alive interval (15s × 3 = 45s timeout) 17 | ServerAliveCountMax = 3, -- Keep-alive message count 18 | dir_cache = "yes", -- Enable directory caching 19 | dcache_timeout = 300, -- Cache timeout in seconds 20 | dcache_max_size = 10000, -- Max cache size 21 | -- allow_other = true, -- Allow other users to access mount 22 | -- uid = "1000,gid=1000", -- Set file ownership (use string for complex values) 23 | -- follow_symlinks = true, -- Follow symbolic links 24 | }, 25 | control_persist = "10m", -- How long to keep ControlMaster connection alive after last use 26 | socket_dir = vim.fn.expand("$HOME/.ssh/sockets"), -- Directory for ControlMaster sockets 27 | }, 28 | mounts = { 29 | base_dir = vim.fn.expand("$HOME") .. "/mnt", -- where remote mounts are created 30 | }, 31 | global_paths = { 32 | -- Optionally define default mount paths for ALL hosts 33 | -- These appear as options when connecting to any host 34 | -- Examples: 35 | -- "~/.config", 36 | -- "/var/www", 37 | -- "/srv", 38 | -- "/opt" 39 | -- "/var/log", 40 | -- "/etc", 41 | -- "/tmp", 42 | -- "/usr/local", 43 | -- "/data", 44 | -- "/var/lib", 45 | }, 46 | host_paths = { 47 | -- Optionally define default mount paths for specific hosts 48 | -- These are shown in addition to global_paths 49 | -- Single path (string): 50 | -- ["my-server"] = "/var/www/html" 51 | -- 52 | -- Multiple paths (array): 53 | -- ["dev-server"] = { "/var/www", "~/projects", "/opt/app" } 54 | }, 55 | hooks = { 56 | on_mount = { 57 | auto_change_to_dir = false, -- auto-change current directory to mount point 58 | -- Action to run after a successful mount 59 | -- "find" (default): open file picker 60 | -- "grep": open grep picker 61 | -- "live_find": run remote find over SSH and stream results 62 | -- "live_grep": run remote rg/grep over SSH and stream results 63 | -- "terminal": open SSH terminal to the mounted host 64 | -- "none" or nil: do nothing 65 | -- function(ctx): custom handler with { mount_path, host, remote_path } 66 | auto_run = "find", 67 | }, 68 | on_exit = { 69 | auto_unmount = true, -- auto-disconnect all mounts on :q or exit 70 | clean_mount_folders = true, -- Unmounts all mounts on nvim exit 71 | }, 72 | }, 73 | ui = { 74 | -- Used for mounted file operations 75 | local_picker = { 76 | preferred_picker = "auto", -- "auto", "telescope", "oil", "neo-tree", "nvim-tree", "snacks", "fzf-lua", "mini", "yazi", "lf", "nnn", "ranger", "netrw" 77 | fallback_to_netrw = true, -- fallback to netrw if no picker is available 78 | netrw_command = "Explore", -- "Explore", "Lexplore", "Sexplore", "Vexplore", "Texplore" 79 | }, 80 | -- Used for remote streaming operations (live_grep, live_find) 81 | remote_picker = { 82 | preferred_picker = "auto", -- "auto", "telescope", "fzf-lua", "snacks", "mini" 83 | }, 84 | }, 85 | keymaps = nil, -- Override individual keymaps (e.g., {mount = "mm", unmount = "mu"}) 86 | lead_prefix = "m", -- Prefix for default keymaps 87 | } 88 | -- stylua: ignore end 89 | 90 | -- Active configuration 91 | Config.options = vim.deepcopy(DEFAULT_CONFIG) 92 | 93 | --- Setup configuration 94 | ---@param user_config table|nil User configuration to merge with defaults 95 | function Config.setup(user_config) 96 | user_config = user_config or {} 97 | 98 | -- TODO: Delete after January 15th from here... 99 | -- Backward compatibility shims for renamed config keys (remove after next release) 100 | local function apply_deprecations(cfg) 101 | cfg = cfg or {} 102 | 103 | -- ui.file_picker -> ui.local_picker 104 | if cfg.ui and cfg.ui.file_picker then 105 | cfg.ui.local_picker = vim.tbl_deep_extend("force", cfg.ui.local_picker or {}, cfg.ui.file_picker) 106 | vim.notify( 107 | "sshfs.nvim: `ui.file_picker` is deprecated; use `ui.local_picker` (will be removed in next release)", 108 | vim.log.levels.WARN 109 | ) 110 | end 111 | 112 | -- mounts.unmount_on_exit -> hooks.on_exit.auto_unmount 113 | if cfg.mounts and cfg.mounts.unmount_on_exit ~= nil then 114 | cfg.hooks = cfg.hooks or {} 115 | cfg.hooks.on_exit = cfg.hooks.on_exit or {} 116 | cfg.hooks.on_exit.auto_unmount = cfg.mounts.unmount_on_exit 117 | vim.notify( 118 | "sshfs.nvim: `mounts.unmount_on_exit` is deprecated; use `hooks.on_exit.auto_unmount` (will be removed in next release)", 119 | vim.log.levels.WARN 120 | ) 121 | end 122 | 123 | -- mounts.auto_change_dir_on_mount -> hooks.on_mount.auto_change_to_dir 124 | if cfg.mounts and cfg.mounts.auto_change_dir_on_mount ~= nil then 125 | cfg.hooks = cfg.hooks or {} 126 | cfg.hooks.on_mount = cfg.hooks.on_mount or {} 127 | cfg.hooks.on_mount.auto_change_to_dir = cfg.mounts.auto_change_dir_on_mount 128 | vim.notify( 129 | "sshfs.nvim: `mounts.auto_change_dir_on_mount` is deprecated; use `hooks.on_mount.auto_change_to_dir` (will be removed in next release)", 130 | vim.log.levels.WARN 131 | ) 132 | end 133 | 134 | return cfg 135 | end 136 | 137 | user_config = apply_deprecations(user_config) 138 | -- TODO: To here... 139 | Config.options = vim.tbl_deep_extend("force", DEFAULT_CONFIG, user_config) 140 | end 141 | 142 | --- Get current configuration 143 | ---@return table config The current configuration 144 | function Config.get() 145 | return Config.options 146 | end 147 | 148 | --- Get the configured base directory for mounts 149 | ---@return string base_dir The base directory path for mounts 150 | function Config.get_base_dir() 151 | local opts = Config.options 152 | return opts.mounts and opts.mounts.base_dir 153 | end 154 | 155 | --- Get ControlMaster options for SSH/SSHFS 156 | ---@return table Array of ControlMaster options 157 | function Config.get_control_master_options() 158 | local opts = Config.options 159 | local socket_dir = Config.get_socket_dir() 160 | local control_path = socket_dir .. "/%C" 161 | local control_persist = (opts.connections and opts.connections.control_persist) or "10m" 162 | 163 | return { 164 | "ControlMaster=auto", 165 | "ControlPath=" .. control_path, 166 | "ControlPersist=" .. control_persist, 167 | } 168 | end 169 | 170 | --- Get SSH socket directory path 171 | ---@return string socket_dir The socket directory path 172 | function Config.get_socket_dir() 173 | local opts = Config.options 174 | return (opts.connections and opts.connections.socket_dir) or vim.fn.expand("$HOME/.ssh/sockets") 175 | end 176 | 177 | return Config 178 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/mini.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/mini.lua 2 | -- Mini.pick file picker and search integration 3 | 4 | local Mini = {} 5 | 6 | --- Attempts to open mini.pick file picker 7 | ---@param cwd string Current working directory to open picker in 8 | ---@return boolean success True if mini.pick was successfully opened 9 | function Mini.explore_files(cwd) 10 | local ok, mini_pick = pcall(require, "mini.pick") 11 | if ok and mini_pick.builtin and mini_pick.builtin.files then 12 | mini_pick.builtin.files({}, { source = { cwd = cwd } }) 13 | return true 14 | end 15 | return false 16 | end 17 | 18 | --- Attempts to open mini.pick live grep search 19 | ---@param cwd string Current working directory to search in 20 | ---@param pattern? string Optional search pattern to pre-populate 21 | ---@return boolean success True if mini.pick was successfully opened 22 | function Mini.grep(cwd, pattern) 23 | local ok, mini_pick = pcall(require, "mini.pick") 24 | if ok and mini_pick.builtin and mini_pick.builtin.grep_live then 25 | local opts = { source = { cwd = cwd } } 26 | if pattern and pattern ~= "" then 27 | opts.input = vim.split(pattern, "") 28 | end 29 | mini_pick.builtin.grep_live({}, opts) 30 | return true 31 | end 32 | return false 33 | end 34 | 35 | --- Attempts to open mini.pick live grep with remote SSH execution 36 | ---@param host string SSH host name 37 | ---@param mount_path string Local mount path to map remote files 38 | ---@param path? string Optional remote path to search (defaults to home) 39 | ---@param callback? function Optional callback(success: boolean) 40 | function Mini.live_grep(host, mount_path, path, callback) 41 | local mini_ok, mini_pick = pcall(require, "mini.pick") 42 | if not mini_ok then 43 | if callback then 44 | callback(false) 45 | end 46 | return 47 | end 48 | 49 | local Ssh = require("sshfs.lib.ssh") 50 | local Path = require("sshfs.lib.path") 51 | local remote_path = path or "." 52 | 53 | -- Handler for opening grep results 54 | local function open_grep_result(item) 55 | if not item then 56 | vim.notify("No item selected", vim.log.levels.WARN) 57 | return 58 | end 59 | 60 | -- Parse grep output: filename:line:column:text 61 | local filename, lnum, col, _ = item:match("^([^:]+):(%d+):(%d+):(.*)$") 62 | if not filename then 63 | filename, lnum, _ = item:match("^([^:]+):(%d+):(.*)$") 64 | col = "1" 65 | end 66 | 67 | if not filename or not lnum then 68 | vim.notify("Failed to parse grep output: " .. item, vim.log.levels.ERROR) 69 | return 70 | end 71 | 72 | local relative_file = Path.map_remote_to_relative(filename, remote_path) 73 | local local_file = mount_path .. "/" .. relative_file 74 | 75 | -- Stop picker and schedule file opening after UI updates 76 | vim.schedule(function() 77 | local open_ok, err = pcall(function() 78 | vim.cmd("edit +" .. lnum .. " " .. vim.fn.fnameescape(local_file)) 79 | if col then 80 | vim.api.nvim_win_set_cursor(0, { tonumber(lnum), tonumber(col) - 1 }) 81 | end 82 | end) 83 | 84 | if not open_ok then 85 | vim.notify("Error opening file: " .. tostring(err), vim.log.levels.ERROR) 86 | end 87 | end) 88 | end 89 | 90 | -- MiniPick source that refreshes items on every keystroke. 91 | -- `items` is initialized empty; `match` repopulates it per query. 92 | local set_items_opts = { do_match = false } 93 | local source = { 94 | name = "Remote Grep (" .. host .. ")", 95 | items = {}, 96 | match = function(_, _, query) 97 | -- mini.pick passes the current input as a table of characters. 98 | if type(query) == "table" then 99 | query = table.concat(query) 100 | end 101 | if not query or query == "" then 102 | return mini_pick.set_picker_items({}, set_items_opts) 103 | end 104 | 105 | -- Build SSH grep command 106 | local ssh_cmd = Ssh.build_command(host) 107 | local grep_cmd = string.format( 108 | "rg --color=never --no-heading --with-filename --line-number --column --smart-case -- %s %s 2>/dev/null || grep -r -n -H -- %s %s", 109 | vim.fn.shellescape(query), 110 | remote_path, 111 | vim.fn.shellescape(query), 112 | remote_path 113 | ) 114 | table.insert(ssh_cmd, grep_cmd) 115 | 116 | -- Execute command and collect results 117 | local output = vim.fn.systemlist(ssh_cmd) 118 | local results = {} 119 | for _, line in ipairs(output) do 120 | if line and line ~= "" then 121 | table.insert(results, line) 122 | end 123 | end 124 | 125 | -- Replace picker items without extra matching 126 | return mini_pick.set_picker_items(results, set_items_opts) 127 | end, 128 | choose = function(item) 129 | open_grep_result(item) 130 | end, 131 | choose_marked = function(chosen) 132 | if chosen and #chosen > 0 then 133 | for _, item in ipairs(chosen) do 134 | open_grep_result(item) 135 | end 136 | end 137 | end, 138 | } 139 | 140 | -- Start the picker with the custom source 141 | mini_pick.start({ 142 | source = source, 143 | }) 144 | 145 | if callback then 146 | callback(true) 147 | end 148 | end 149 | 150 | --- Attempts to open mini.pick live find with remote SSH execution 151 | ---@param host string SSH host name 152 | ---@param mount_path string Local mount path to map remote files 153 | ---@param path? string Optional remote path to search (defaults to home) 154 | ---@param callback? function Optional callback(success: boolean) 155 | function Mini.live_find(host, mount_path, path, callback) 156 | local mini_ok, mini_pick = pcall(require, "mini.pick") 157 | if not mini_ok then 158 | if callback then 159 | callback(false) 160 | end 161 | return 162 | end 163 | 164 | local Ssh = require("sshfs.lib.ssh") 165 | local Path = require("sshfs.lib.path") 166 | local remote_path = path or "." 167 | 168 | -- Handler for opening file results 169 | local function open_file_result(item) 170 | if not item then 171 | vim.notify("No item selected", vim.log.levels.WARN) 172 | return 173 | end 174 | 175 | local filename = item 176 | if not filename or filename == "" then 177 | vim.notify("Empty filename", vim.log.levels.ERROR) 178 | return 179 | end 180 | 181 | local relative_file = Path.map_remote_to_relative(filename, remote_path) 182 | local local_file = mount_path .. "/" .. relative_file 183 | 184 | -- Schedule file opening after UI updates 185 | vim.schedule(function() 186 | local open_ok, err = pcall(function() 187 | vim.cmd("edit " .. vim.fn.fnameescape(local_file)) 188 | end) 189 | 190 | if not open_ok then 191 | vim.notify("Error opening file: " .. tostring(err), vim.log.levels.ERROR) 192 | end 193 | end) 194 | end 195 | 196 | -- Build SSH find command once 197 | local ssh_cmd = Ssh.build_command(host) 198 | local find_cmd = 199 | string.format("fd --color=never --type=f . %s 2>/dev/null || find %s -type f", remote_path, remote_path) 200 | table.insert(ssh_cmd, find_cmd) 201 | 202 | -- Execute command once and get all files 203 | local output = vim.fn.systemlist(ssh_cmd) 204 | local items = {} 205 | for _, line in ipairs(output) do 206 | if line and line ~= "" then 207 | table.insert(items, line) 208 | end 209 | end 210 | 211 | -- Create a custom source for file finding 212 | local source = { 213 | name = "Remote Find (" .. host .. ")", 214 | items = items, 215 | choose = function(item) 216 | open_file_result(item) 217 | end, 218 | choose_marked = function(chosen) 219 | if chosen and #chosen > 0 then 220 | for _, item in ipairs(chosen) do 221 | open_file_result(item) 222 | end 223 | end 224 | end, 225 | } 226 | 227 | -- Start the picker with the custom source 228 | mini_pick.start({ 229 | source = source, 230 | }) 231 | 232 | if callback then 233 | callback(true) 234 | end 235 | end 236 | 237 | return Mini 238 | -------------------------------------------------------------------------------- /lua/sshfs/integrations/telescope.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/integrations/telescope.lua 2 | -- Telescope file picker and search integration 3 | 4 | local Telescope = {} 5 | 6 | --- Attempts to open telescope file picker 7 | ---@param cwd string Current working directory to open picker in 8 | ---@return boolean success True if telescope was successfully opened 9 | function Telescope.explore_files(cwd) 10 | local ok, telescope = pcall(require, "telescope.builtin") 11 | if ok and telescope.find_files then 12 | telescope.find_files({ cwd = cwd }) 13 | return true 14 | end 15 | return false 16 | end 17 | 18 | --- Attempts to open telescope live grep search 19 | ---@param cwd string Current working directory to search in 20 | ---@param pattern? string Optional search pattern to pre-populate 21 | ---@return boolean success True if telescope was successfully opened 22 | function Telescope.grep(cwd, pattern) 23 | local ok, telescope = pcall(require, "telescope.builtin") 24 | if ok and telescope.live_grep then 25 | local opts = { cwd = cwd } 26 | if pattern and pattern ~= "" then 27 | opts.default_text = pattern 28 | end 29 | telescope.live_grep(opts) 30 | return true 31 | end 32 | return false 33 | end 34 | 35 | --- Creates telescope entry maker that maps SSH remote paths to SSHFS mount paths 36 | ---@param mount_path string Local SSHFS mount path 37 | ---@param remote_path string Remote SSH path being searched 38 | ---@param parse_fn function Function that parses SSH command output line into structured data 39 | ---@return function entry_maker Telescope entry maker function 40 | local function ssh_to_sshfs_entry_maker(mount_path, remote_path, parse_fn) 41 | return function(line) 42 | local Path = require("sshfs.lib.path") 43 | local data = parse_fn(line) 44 | 45 | if not data or not data.filename then 46 | return nil 47 | end 48 | 49 | local relative_file = Path.map_remote_to_relative(data.filename, remote_path) 50 | local local_path = mount_path .. "/" .. relative_file 51 | 52 | return { 53 | value = line, 54 | display = data.display or relative_file, 55 | ordinal = data.ordinal or relative_file, 56 | path = local_path, 57 | filename = local_path, 58 | lnum = data.lnum, 59 | col = data.col, 60 | text = data.text, 61 | } 62 | end 63 | end 64 | 65 | --- Attempts to open telescope live grep with remote SSH execution 66 | ---@param host string SSH host name 67 | ---@param mount_path string Local mount path to map remote files 68 | ---@param path? string Optional remote path to search (defaults to home) 69 | ---@param callback? function Optional callback(success: boolean) 70 | function Telescope.live_grep(host, mount_path, path, callback) 71 | local ok_pickers, pickers = pcall(require, "telescope.pickers") 72 | local ok_finders, finders = pcall(require, "telescope.finders") 73 | local ok_make_entry, make_entry = pcall(require, "telescope.make_entry") 74 | local ok_conf, conf = pcall(require, "telescope.config") 75 | local ok_previewers, previewers = pcall(require, "telescope.previewers") 76 | 77 | if not (ok_pickers and ok_finders and ok_make_entry and ok_conf and ok_previewers) then 78 | if callback then 79 | callback(false) 80 | end 81 | return 82 | end 83 | 84 | local Ssh = require("sshfs.lib.ssh") 85 | local remote_path = path or "." 86 | 87 | -- Parser for vimgrep output format 88 | local function parse_grep_line(line) 89 | -- Parse: filename:line:col:text or filename:line:text 90 | local filename, lnum, col, text = line:match("^([^:]+):(%d+):(%d+):(.*)$") 91 | if not filename then 92 | filename, lnum, text = line:match("^([^:]+):(%d+):(.*)$") 93 | col = nil 94 | end 95 | 96 | return { 97 | filename = filename, 98 | lnum = tonumber(lnum), 99 | col = col and tonumber(col) or 1, 100 | text = text, 101 | } 102 | end 103 | 104 | -- Create job-based finder that runs remote grep 105 | local live_grepper = finders.new_job(function(prompt) 106 | if not prompt or prompt == "" then 107 | return nil 108 | end 109 | 110 | -- Build command: ssh host "rg ... || grep ..." 111 | local ssh_cmd = Ssh.build_command(host) 112 | local grep_cmd = string.format( 113 | "rg --color=never --no-heading --with-filename --line-number --column --smart-case -- %s %s 2>/dev/null || grep -r -n -H -- %s %s", 114 | vim.fn.shellescape(prompt), 115 | remote_path, 116 | vim.fn.shellescape(prompt), 117 | remote_path 118 | ) 119 | table.insert(ssh_cmd, grep_cmd) 120 | return ssh_cmd 121 | end, ssh_to_sshfs_entry_maker(mount_path, remote_path, parse_grep_line)) 122 | 123 | -- Custom SSH-based previewer for grep results 124 | local ssh_grep_previewer = previewers.new_termopen_previewer({ 125 | get_command = function(entry) 126 | if not entry or not entry.value then 127 | return { "echo", "No preview available" } 128 | end 129 | 130 | -- Parse the grep output to get filename and line 131 | local data = parse_grep_line(entry.value) 132 | if not data.filename then 133 | return { "echo", "No preview available" } 134 | end 135 | 136 | -- Build SSH command to preview file with bat/cat 137 | -- Limit to first 10000 lines to avoid huge files, bat handles binary files gracefully 138 | local ssh_cmd = Ssh.build_command(host) 139 | local preview_cmd = string.format( 140 | "bat --color=always --style=numbers --highlight-line=%d --line-range=:10000 %s 2>/dev/null || head -n 10000 %s", 141 | data.lnum or 1, 142 | vim.fn.shellescape(data.filename), 143 | vim.fn.shellescape(data.filename) 144 | ) 145 | table.insert(ssh_cmd, preview_cmd) 146 | return ssh_cmd 147 | end, 148 | }) 149 | 150 | pickers 151 | .new({}, { 152 | prompt_title = "Remote Live Grep (" .. host .. ")", 153 | finder = live_grepper, 154 | previewer = ssh_grep_previewer, 155 | sorter = conf.values.generic_sorter({}), 156 | }) 157 | :find() 158 | 159 | if callback then 160 | callback(true) 161 | end 162 | end 163 | 164 | --- Attempts to open telescope live find with remote SSH execution 165 | ---@param host string SSH host name 166 | ---@param mount_path string Local mount path to map remote files 167 | ---@param path? string Optional remote path to search (defaults to home) 168 | ---@param callback? function Optional callback(success: boolean) 169 | function Telescope.live_find(host, mount_path, path, callback) 170 | local ok_pickers, pickers = pcall(require, "telescope.pickers") 171 | local ok_finders, finders = pcall(require, "telescope.finders") 172 | local ok_make_entry, make_entry = pcall(require, "telescope.make_entry") 173 | local ok_conf, conf = pcall(require, "telescope.config") 174 | local ok_previewers, previewers = pcall(require, "telescope.previewers") 175 | 176 | if not (ok_pickers and ok_finders and ok_make_entry and ok_conf and ok_previewers) then 177 | if callback then 178 | callback(false) 179 | end 180 | return 181 | end 182 | 183 | local Ssh = require("sshfs.lib.ssh") 184 | local remote_path = path or "." 185 | 186 | -- Parser for find output (just filenames) 187 | local function parse_find_line(line) 188 | return { 189 | filename = line, 190 | } 191 | end 192 | 193 | -- Build command: ssh host "fd ... || find ..." 194 | local ssh_cmd = Ssh.build_command(host) 195 | local find_cmd = 196 | string.format("fd --color=never --type=f . %s 2>/dev/null || find %s -type f", remote_path, remote_path) 197 | table.insert(ssh_cmd, find_cmd) 198 | 199 | -- Create oneshot job finder that lists all remote files 200 | local file_finder = finders.new_oneshot_job(ssh_cmd, { 201 | entry_maker = ssh_to_sshfs_entry_maker(mount_path, remote_path, parse_find_line), 202 | }) 203 | 204 | -- Custom SSH-based previewer for file contents 205 | local ssh_file_previewer = previewers.new_termopen_previewer({ 206 | get_command = function(entry) 207 | if not entry or not entry.value then 208 | return { "echo", "No preview available" } 209 | end 210 | 211 | -- Get the filename from the entry 212 | local filename = entry.value 213 | 214 | -- Build SSH command to preview file with bat/cat 215 | -- Limit to first 10000 lines to avoid huge files, bat handles binary files gracefully 216 | local preview_ssh_cmd = Ssh.build_command(host) 217 | local preview_cmd = string.format( 218 | "bat --color=always --style=numbers --line-range=:10000 %s 2>/dev/null || head -n 10000 %s", 219 | vim.fn.shellescape(filename), 220 | vim.fn.shellescape(filename) 221 | ) 222 | table.insert(preview_ssh_cmd, preview_cmd) 223 | return preview_ssh_cmd 224 | end, 225 | }) 226 | 227 | pickers 228 | .new({}, { 229 | prompt_title = "Remote Find (" .. host .. ")", 230 | finder = file_finder, 231 | previewer = ssh_file_previewer, 232 | sorter = conf.values.file_sorter({}), 233 | }) 234 | :find() 235 | 236 | if callback then 237 | callback(true) 238 | end 239 | end 240 | 241 | return Telescope 242 | -------------------------------------------------------------------------------- /lua/sshfs/lib/mount_point.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/lib/mount_point.lua 2 | -- Mount point management, detection, creation, unmounting, cleanup, and command execution 3 | 4 | local MountPoint = {} 5 | local Directory = require("sshfs.lib.directory") 6 | local Config = require("sshfs.config") 7 | 8 | --- Get all active SSHFS mount paths and remote info from the system 9 | --- @return table Array of mount info tables with {mount_path, remote_spec} where remote_spec is "user@host:/path" or "host:/path" 10 | local function get_system_mounts() 11 | local mounts = {} 12 | 13 | -- Try findmnt first (Linux only) if available - it can show both SOURCE and TARGET 14 | if vim.fn.executable("findmnt") == 1 then 15 | local findmnt_result = vim.fn.system({ "findmnt", "-t", "fuse.sshfs", "-n", "-o", "SOURCE,TARGET" }) 16 | if vim.v.shell_error == 0 then 17 | for line in findmnt_result:gmatch("[^\r\n]+") do 18 | -- findmnt output: "user@host:/remote/path /local/mount" 19 | local remote_spec, mount_path = line:match("^(%S+)%s+(.+)$") 20 | if remote_spec and mount_path then 21 | table.insert(mounts, { mount_path = mount_path, remote_spec = remote_spec }) 22 | end 23 | end 24 | return mounts 25 | end 26 | end 27 | 28 | -- Fallback to mount command for broader compatibility 29 | local result = vim.fn.system("mount") 30 | if vim.v.shell_error ~= 0 then 31 | return mounts 32 | end 33 | 34 | -- Cross-platform patterns for detecting SSHFS mounts with remote spec 35 | -- Format: "user@host:/remote/path on /mount/path type fuse.sshfs" or "... (macfuse" 36 | local pattern_templates = { 37 | "^(%S+)%s+on%s+([^%s]+)%s+type%s+fuse%.sshfs", -- Linux: "user@host:/path on /mount/path type fuse.sshfs" 38 | "^(%S+)%s+on%s+([^%s]+)%s+%(macfuse", -- macOS/macfuse: "user@host:/path on /mount/path (macfuse" 39 | "^(%S+)%s+on%s+([^%s]+)%s+%(osxfuse", -- macOS/osxfuse older: "user@host:/path on /mount/path (osxfuse" 40 | "^(%S+)%s+on%s+([^%s]+)%s+%(fuse", -- Generic FUSE: "user@host:/path on /mount/path (fuse" 41 | } 42 | 43 | -- Only process lines that contain 'sshfs' to avoid false positives 44 | for line in result:gmatch("[^\r\n]+") do 45 | if line:match("sshfs") then 46 | for _, pattern in ipairs(pattern_templates) do 47 | local remote_spec, mount_path = line:match(pattern) 48 | if remote_spec and mount_path then 49 | table.insert(mounts, { mount_path = mount_path, remote_spec = remote_spec }) 50 | break 51 | end 52 | end 53 | end 54 | end 55 | 56 | return mounts 57 | end 58 | 59 | --- Check if a mount path is actively mounted 60 | --- @param mount_path string Path to check for active mount 61 | --- @return boolean True if mount is active 62 | function MountPoint.is_active(mount_path) 63 | local stat = vim.uv.fs_stat(mount_path) 64 | if not stat or stat.type ~= "directory" then 65 | return false 66 | end 67 | 68 | local mounts = get_system_mounts() 69 | for _, mount in ipairs(mounts) do 70 | if mount.mount_path == mount_path then 71 | return true 72 | end 73 | end 74 | 75 | return false 76 | end 77 | 78 | --- List all active sshfs mounts 79 | --- @return table Array of mount objects with host, mount_path, and remote_path fields 80 | function MountPoint.list_active() 81 | local mounts = {} 82 | local base_mount_dir = Config.get_base_dir() 83 | local system_mounts = get_system_mounts() 84 | 85 | -- Filter to only include mounts under our base directory 86 | local mount_dir_escaped = base_mount_dir:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") 87 | local prefix_pattern = "^" .. mount_dir_escaped .. "/(.+)$" 88 | 89 | for _, mount_info in ipairs(system_mounts) do 90 | local host = mount_info.mount_path:match(prefix_pattern) 91 | if host and host ~= "" then 92 | -- Parse remote_spec to extract remote path 93 | -- Format: "user@host:/remote/path" or "host:/remote/path" 94 | local remote_path = mount_info.remote_spec:match(":(.*)$") 95 | 96 | table.insert(mounts, { 97 | host = host, 98 | mount_path = mount_info.mount_path, 99 | remote_path = remote_path or "/", -- Default to root if parsing fails 100 | }) 101 | end 102 | end 103 | 104 | return mounts 105 | end 106 | 107 | --- Check if any active mounts exist 108 | --- @return boolean True if any active mounts exist 109 | function MountPoint.has_active() 110 | local mounts = MountPoint.list_active() 111 | return #mounts > 0 112 | end 113 | 114 | --- Get first active mount (for backward compatibility with single-mount workflows) 115 | --- @return table|nil Mount object with host, mount_path, and remote_path fields, or nil if none 116 | function MountPoint.get_active() 117 | local mounts = MountPoint.list_active() 118 | if #mounts > 0 then 119 | return mounts[1] 120 | end 121 | return nil 122 | end 123 | 124 | --- Get or create mount directory 125 | --- @param mount_dir string|nil Directory path (defaults to base mount dir from config) 126 | --- @return boolean True if directory exists or was created successfully 127 | function MountPoint.get_or_create(mount_dir) 128 | mount_dir = mount_dir or Config.get_base_dir() 129 | local stat = vim.uv.fs_stat(mount_dir) 130 | if stat and stat.type == "directory" then 131 | return true 132 | end 133 | 134 | local success = vim.fn.mkdir(mount_dir, "p") 135 | return success == 1 136 | end 137 | 138 | --- Unmount an sshfs mount using fusermount/umount 139 | --- @param mount_path string Path to unmount 140 | --- @return boolean True if unmount succeeded 141 | function MountPoint.unmount(mount_path) 142 | local commands = { 143 | { "fusermount", { "-u", mount_path } }, 144 | { "fusermount3", { "-u", mount_path } }, 145 | { "umount", { "-l", mount_path } }, -- Linux: lazy unmount 146 | { "umount", { mount_path } }, -- macOS/BSD: standard unmount 147 | { "diskutil", { "unmount", mount_path } }, -- macOS: fallback 148 | } 149 | 150 | for _, cmd in ipairs(commands) do 151 | local command, args = cmd[1], cmd[2] 152 | 153 | -- Try command if executable with jobstart 154 | if vim.fn.executable(command) == 1 then 155 | local job_id = vim.fn.jobstart(vim.list_extend({ command }, args), { 156 | stdout_buffered = true, 157 | stderr_buffered = true, 158 | }) 159 | local exit_code = -1 160 | if job_id > 0 then 161 | local result = vim.fn.jobwait({ job_id }, 5000)[1] -- 5 second timeout 162 | exit_code = result or -1 163 | end 164 | 165 | if exit_code == 0 then 166 | vim.fn.delete(mount_path, "d") 167 | return true 168 | end 169 | end 170 | end 171 | 172 | return false 173 | end 174 | 175 | --- Clean up base mount directory if empty 176 | --- @return boolean True if cleanup succeeded 177 | function MountPoint.cleanup() 178 | local base_mount_dir = Config.get_base_dir() 179 | local stat = vim.uv.fs_stat(base_mount_dir) 180 | if stat and stat.type == "directory" and Directory.is_empty(base_mount_dir) then 181 | vim.fn.delete(base_mount_dir, "d") 182 | return true 183 | end 184 | return false 185 | end 186 | 187 | --- Clean up stale mount directories that are empty and not actively mounted 188 | --- Only removes empty directories to avoid interfering with user-managed mounts. 189 | --- This is useful after unclean unmounts (crashes, force-kills, etc.) that leave empty mount points. 190 | --- @return number Count of directories removed 191 | function MountPoint.cleanup_stale() 192 | local base_mount_dir = Config.get_base_dir() 193 | local stat = vim.uv.fs_stat(base_mount_dir) 194 | if not stat or stat.type ~= "directory" then 195 | return 0 196 | end 197 | 198 | -- Scan for directories in base_mount_dir 199 | local files = vim.fn.glob(base_mount_dir .. "/*", false, true) 200 | local removed_count = 0 201 | 202 | for _, file in ipairs(files) do 203 | if vim.fn.isdirectory(file) == 1 then 204 | -- Only remove if directory is empty AND not actively mounted 205 | if Directory.is_empty(file) and not MountPoint.is_active(file) then 206 | MountPoint.unmount(file) 207 | local success = pcall(vim.fn.delete, file, "d") 208 | if success then 209 | removed_count = removed_count + 1 210 | end 211 | end 212 | end 213 | end 214 | 215 | return removed_count 216 | end 217 | 218 | --- Run a command on a mounted directory 219 | --- Prompts user to select a mount if multiple connections are active 220 | --- @param command string|nil Command to run on mount path (e.g., "edit", "tcd", "Oil"). If nil, prompts user for input. 221 | function MountPoint.run_command(command) 222 | local active_connections = MountPoint.list_active() 223 | 224 | if #active_connections == 0 then 225 | vim.notify("No active SSH connections", vim.log.levels.WARN) 226 | return 227 | end 228 | 229 | -- Prompt for command if not provided 230 | if not command then 231 | vim.ui.input({ prompt = "Command to run on mount: ", default = "" }, function(input) 232 | if input and input ~= "" then 233 | MountPoint.run_command(input) 234 | end 235 | end) 236 | return 237 | end 238 | 239 | if #active_connections == 1 then 240 | local mount_dir = active_connections[1].mount_path 241 | vim.cmd(command .. " " .. vim.fn.fnameescape(mount_dir)) 242 | return 243 | end 244 | 245 | local items = {} 246 | for _, conn in ipairs(active_connections) do 247 | table.insert(items, conn.host) 248 | end 249 | 250 | vim.ui.select(items, { 251 | prompt = "Select mount to " .. command .. ":", 252 | }, function(_, idx) 253 | if idx then 254 | local mount_dir = active_connections[idx].mount_path 255 | vim.cmd(command .. " " .. vim.fn.fnameescape(mount_dir)) 256 | end 257 | end) 258 | end 259 | 260 | return MountPoint 261 | -------------------------------------------------------------------------------- /lua/sshfs/lib/ssh.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/lib/ssh.lua 2 | -- SSH operations: terminal sessions, command execution, and connection utilities 3 | 4 | local Ssh = {} 5 | 6 | --- Get SSH socket directory, creating it if it doesn't exist 7 | --- @return string|nil socket_dir The socket directory path, or nil if creation failed 8 | --- @return string|nil error_msg Error message if creation failed 9 | local function get_or_create_socket_dir() 10 | local Config = require("sshfs.config") 11 | local socket_dir = Config.get_socket_dir() 12 | 13 | if vim.fn.isdirectory(socket_dir) == 1 then 14 | return socket_dir, nil 15 | end 16 | 17 | local ok, err = pcall(vim.fn.mkdir, socket_dir, "p", "0700") 18 | if ok then 19 | return socket_dir, nil 20 | else 21 | return nil, "Failed to create socket directory: " .. socket_dir .. " (" .. tostring(err) .. ")" 22 | end 23 | end 24 | 25 | --- Build SSH options array (ControlMaster + optional auth options) 26 | --- @param auth_type string|nil Authentication type: 27 | --- - "batch": ControlMaster=yes + BatchMode=yes (socket creation, non-interactive) 28 | --- - "socket": ControlPath only (reuse existing socket) 29 | --- - nil: ControlMaster=auto (for interactive terminals) 30 | --- @return table Array of SSH option strings (e.g., {"ControlMaster=auto", "ControlPath=...", "BatchMode=yes"}) 31 | local function get_ssh_options(auth_type) 32 | local Config = require("sshfs.config") 33 | local options = {} 34 | 35 | -- Add ControlMaster options 36 | local control_opts = Config.get_control_master_options() 37 | if auth_type == "batch" then 38 | -- For batch connection: use ControlMaster=yes to force socket creation 39 | local modified_opts = {} 40 | for _, opt in ipairs(control_opts) do 41 | if opt:match("^ControlMaster=") then 42 | table.insert(modified_opts, "ControlMaster=yes") 43 | else 44 | table.insert(modified_opts, opt) 45 | end 46 | end 47 | vim.list_extend(options, modified_opts) 48 | table.insert(options, "BatchMode=yes") 49 | elseif auth_type == "socket" then 50 | -- For socket reuse: only add ControlPath (no ControlMaster/ControlPersist) 51 | for _, opt in ipairs(control_opts) do 52 | if opt:match("^ControlPath=") then 53 | table.insert(options, opt) 54 | break 55 | end 56 | end 57 | else 58 | -- Default (nil): use ControlMaster=auto for interactive terminals 59 | vim.list_extend(options, control_opts) 60 | end 61 | 62 | return options 63 | end 64 | 65 | --- Build SSH command string with options for use with sshfs ssh_command option 66 | --- @param auth_type string|nil Authentication type ("batch", "socket", or nil) 67 | --- @return string SSH command string (e.g., "ssh -o ControlMaster=auto -o ControlPath=... -o BatchMode=yes") 68 | function Ssh.build_command_string(auth_type) 69 | local options = get_ssh_options(auth_type) 70 | local cmd_parts = { "ssh" } 71 | 72 | for _, opt in ipairs(options) do 73 | table.insert(cmd_parts, "-o") 74 | table.insert(cmd_parts, opt) 75 | end 76 | 77 | return table.concat(cmd_parts, " ") 78 | end 79 | 80 | --- Build a safe cd command that handles tilde expansion and path escaping 81 | --- @param remote_path string Remote path to cd into 82 | --- @return string Shell command to cd into the path 83 | local function build_cd_command(remote_path) 84 | -- Escape path for safe use in single quotes 85 | local function escape_single_quotes(path) 86 | return "'" .. path:gsub("'", "'\\''") .. "'" 87 | end 88 | 89 | -- Handle ~ paths specially to allow shell expansion 90 | if remote_path == "~" then 91 | return "cd ~" 92 | elseif remote_path:match("^~/") then 93 | local rest = remote_path:sub(3) -- Remove "~/" 94 | return "cd ~ && cd " .. escape_single_quotes(rest) 95 | else 96 | return "cd " .. escape_single_quotes(remote_path) 97 | end 98 | end 99 | 100 | --- Build SSH command with optional remote path and ControlMaster options 101 | ---@param host string SSH host name 102 | ---@param remote_path string|nil Optional remote path to cd into 103 | ---@return table SSH command as array (safer than string to avoid shell injection) 104 | function Ssh.build_command(host, remote_path) 105 | local cmd = { "ssh" } 106 | 107 | -- Add SSH options (ControlMaster, etc.) 108 | local options = get_ssh_options(nil) -- No auth type for interactive terminal 109 | for _, opt in ipairs(options) do 110 | table.insert(cmd, "-o") 111 | table.insert(cmd, opt) 112 | end 113 | 114 | table.insert(cmd, host) 115 | 116 | -- If remote_path specified, cd into it and start a login shell 117 | if remote_path and remote_path ~= "" then 118 | table.insert(cmd, "-t") 119 | local cd_command = build_cd_command(remote_path) 120 | table.insert(cmd, cd_command .. " && exec $SHELL -l") 121 | end 122 | 123 | return cmd 124 | end 125 | 126 | --- Open SSH terminal session 127 | ---@param host string SSH host name 128 | ---@param remote_path string|nil Optional remote path to cd into 129 | function Ssh.open_terminal(host, remote_path) 130 | local ssh_cmd = Ssh.build_command(host, remote_path) 131 | vim.cmd("enew") 132 | vim.fn.jobstart(ssh_cmd, { term = true }) 133 | vim.cmd("startinsert") 134 | end 135 | 136 | --- Get remote home directory by executing 'echo $HOME' on the remote server (async) 137 | --- This handles non-standard home directory structures (e.g., /home//) 138 | --- Uses existing ControlMaster socket if available for zero authentication overhead 139 | ---@param host string SSH host name 140 | ---@param callback function Callback(home_path: string|nil, error: string|nil) 141 | function Ssh.get_remote_home(host, callback) 142 | local cmd = { "ssh" } 143 | 144 | -- Add ControlPath option to reuse existing socket 145 | local options = get_ssh_options("socket") 146 | for _, opt in ipairs(options) do 147 | table.insert(cmd, "-o") 148 | table.insert(cmd, opt) 149 | end 150 | 151 | table.insert(cmd, host) 152 | -- Use readlink -f to resolve symlinks and get the canonical path with fallback if no readlink 153 | table.insert(cmd, "readlink -f $HOME 2>/dev/null || echo $HOME") 154 | 155 | -- Execute asynchronously 156 | vim.system(cmd, { text = true }, function(obj) 157 | vim.schedule(function() 158 | if obj.code == 0 then 159 | local home_path = vim.trim(obj.stdout or "") 160 | if home_path ~= "" and home_path:sub(1, 1) == "/" then 161 | callback(home_path, nil) 162 | else 163 | callback(nil, "Remote $HOME output invalid: '" .. home_path .. "'") 164 | end 165 | else 166 | local error_msg = vim.trim(obj.stderr or obj.stdout or "Unknown error") 167 | callback(nil, error_msg) 168 | end 169 | end) 170 | end) 171 | end 172 | 173 | --- Close ControlMaster connection and clean up socket 174 | --- Sends "exit" command to ControlMaster to gracefully close connection and remove socket 175 | ---@param host string SSH host name 176 | ---@return boolean True if cleanup command was sent successfully 177 | function Ssh.cleanup_control_master(host) 178 | local Config = require("sshfs.config") 179 | local control_opts = Config.get_control_master_options() 180 | 181 | -- Build ssh -O exit command for ControlPath 182 | local cmd = { "ssh" } 183 | for _, opt in ipairs(control_opts) do 184 | table.insert(cmd, "-o") 185 | table.insert(cmd, opt) 186 | end 187 | table.insert(cmd, "-O") 188 | table.insert(cmd, "exit") 189 | table.insert(cmd, host) 190 | 191 | -- Execute synchronously (must complete before nvim exit) 192 | vim.fn.system(cmd) 193 | -- Ignore exit code - socket may already be closed/expired 194 | return true 195 | end 196 | 197 | --- Try batch SSH connection to establish ControlMaster socket (async, non-interactive) 198 | --- Attempts to connect using existing keys without prompting for passwords or passphrases 199 | ---@param host string SSH host name 200 | ---@param callback function Callback(success: boolean, exit_code: number, error: string|nil) 201 | function Ssh.try_batch_connect(host, callback) 202 | -- Ensure socket directory exists before attempting connection 203 | local socket_dir, err = get_or_create_socket_dir() 204 | if not socket_dir then 205 | vim.schedule(function() 206 | callback(false, 1, err) 207 | end) 208 | return 209 | end 210 | 211 | local cmd = { "ssh" } 212 | 213 | -- Add SSH options for batch connection (ControlMaster=yes + BatchMode=yes) 214 | local options = get_ssh_options("batch") 215 | for _, opt in ipairs(options) do 216 | table.insert(cmd, "-o") 217 | table.insert(cmd, opt) 218 | end 219 | 220 | -- Add host and exit command (just test connection, don't start shell) 221 | table.insert(cmd, host) 222 | table.insert(cmd, "exit") 223 | 224 | -- Execute asynchronously 225 | vim.system(cmd, { text = true }, function(obj) 226 | vim.schedule(function() 227 | local success = obj.code == 0 228 | local error_msg = success and nil or (obj.stderr or obj.stdout or "Unknown error") 229 | callback(success, obj.code, error_msg) 230 | end) 231 | end) 232 | end 233 | 234 | --- Open interactive SSH terminal for authentication in floating window (async) 235 | --- Allows user to complete any SSH authentication method (password, 2FA, host verification, etc.) 236 | --- Creates floating terminal window and tracks exit code for success/failure 237 | ---@param host string SSH host name 238 | ---@param callback function Callback(success: boolean, exit_code: number) 239 | function Ssh.open_auth_terminal(host, callback) 240 | -- Ensure socket directory exists before attempting connection 241 | local socket_dir, err = get_or_create_socket_dir() 242 | if not socket_dir then 243 | vim.notify("sshfs.nvim: " .. err, vim.log.levels.ERROR) 244 | vim.schedule(function() 245 | callback(false, 1) 246 | end) 247 | return 248 | end 249 | 250 | -- Build SSH command for authentication (ControlMaster=yes to create socket) 251 | local cmd = { "ssh" } 252 | local options = get_ssh_options(nil) -- Get ControlMaster options 253 | local modified_opts = {} 254 | for _, opt in ipairs(options) do 255 | if opt:match("^ControlMaster=") then 256 | table.insert(modified_opts, "ControlMaster=yes") 257 | else 258 | table.insert(modified_opts, opt) 259 | end 260 | end 261 | 262 | -- Finalize command options, end with exit to close shell after authentication flow 263 | for _, opt in ipairs(modified_opts) do 264 | table.insert(cmd, "-o") 265 | table.insert(cmd, opt) 266 | end 267 | table.insert(cmd, host) 268 | table.insert(cmd, "exit") 269 | 270 | -- Open authentication terminal window 271 | local Terminal = require("sshfs.ui.terminal") 272 | Terminal.open_auth_floating(cmd, host, callback) 273 | end 274 | 275 | return Ssh 276 | -------------------------------------------------------------------------------- /lua/sshfs/ui/picker.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/ui/picker.lua 2 | -- File picker and search picker auto-detection with fallbacks 3 | 4 | local Picker = {} 5 | local Config = require("sshfs.config") 6 | 7 | -- Integration registry, modules are lazy-loaded on first use 8 | local FILE_PICKERS = { 9 | -- In order of speed on SSHFS 10 | { name = "snacks", module = "sshfs.integrations.snacks", method = "explore_files" }, -- FAST 11 | { name = "yazi", module = "sshfs.integrations.yazi", method = "explore_files" }, -- FAST 12 | { name = "telescope", module = "sshfs.integrations.telescope", method = "explore_files" }, -- FAST 13 | { name = "oil", module = "sshfs.integrations.oil", method = "explore_files" }, -- FAST 14 | { name = "lf", module = "sshfs.integrations.lf", method = "explore_files" }, -- FAST 15 | { name = "ranger", module = "sshfs.integrations.ranger", method = "explore_files" }, -- FAST 16 | { name = "nnn", module = "sshfs.integrations.nnn", method = "explore_files" }, -- FAST 17 | { name = "neo-tree", module = "sshfs.integrations.neo_tree", method = "explore_files" }, -- MEDIUM 18 | { name = "mini", module = "sshfs.integrations.mini", method = "explore_files" }, -- SLOW 19 | { name = "nvim-tree", module = "sshfs.integrations.nvim_tree", method = "explore_files" }, -- SLOW 20 | { name = "fzf-lua", module = "sshfs.integrations.fzf_lua", method = "explore_files" }, -- SLOW 21 | { name = "netrw", module = "sshfs.integrations.netrw", method = "explore_files" }, -- FAST 22 | } 23 | 24 | local SEARCH_PICKERS = { 25 | -- In order of speed on SSHFS 26 | { name = "snacks", module = "sshfs.integrations.snacks", method = "grep" }, 27 | { name = "telescope", module = "sshfs.integrations.telescope", method = "grep" }, 28 | { name = "mini", module = "sshfs.integrations.mini", method = "grep" }, 29 | { name = "fzf-lua", module = "sshfs.integrations.fzf_lua", method = "grep" }, 30 | { name = "builtin", module = "sshfs.integrations.builtin", method = "grep" }, 31 | } 32 | 33 | local LIVE_REMOTE_GREP_PICKERS = { 34 | -- In order of speed/user experience 35 | { name = "snacks", module = "sshfs.integrations.snacks", method = "live_grep" }, 36 | { name = "fzf-lua", module = "sshfs.integrations.fzf_lua", method = "live_grep" }, 37 | { name = "telescope", module = "sshfs.integrations.telescope", method = "live_grep" }, 38 | { name = "mini", module = "sshfs.integrations.mini", method = "live_grep" }, 39 | } 40 | 41 | local LIVE_REMOTE_FIND_PICKERS = { 42 | -- In order of speed/user experience 43 | { name = "snacks", module = "sshfs.integrations.snacks", method = "live_find" }, 44 | { name = "fzf-lua", module = "sshfs.integrations.fzf_lua", method = "live_find" }, 45 | { name = "telescope", module = "sshfs.integrations.telescope", method = "live_find" }, 46 | { name = "mini", module = "sshfs.integrations.mini", method = "live_find" }, 47 | } 48 | 49 | -- Cache for loaded integration modules 50 | local INTEGRATION_CACHE = {} 51 | 52 | --- Lazy-load and cache an integration module 53 | ---@param module_path string Module path to require 54 | ---@return table integration The loaded integration module 55 | ---@private 56 | local function get_integration(module_path) 57 | if not INTEGRATION_CACHE[module_path] then 58 | INTEGRATION_CACHE[module_path] = require(module_path) 59 | end 60 | return INTEGRATION_CACHE[module_path] 61 | end 62 | 63 | --- Generic function to try opening a picker from a registry 64 | ---@param picker_registry table List of picker definitions with name, module, and method 65 | ---@param preferred string|nil Preferred picker name, or "auto" for auto-detection 66 | ---@param args table Arguments to pass to the picker method 67 | ---@return boolean success True if a picker was successfully opened 68 | ---@return string picker_name Name of the picker that was opened, or error message 69 | ---@private 70 | local function try_picker(picker_registry, preferred, args) 71 | -- Try preferred picker first if specified 72 | if preferred and preferred ~= "auto" then 73 | for _, picker in ipairs(picker_registry) do 74 | if picker.name == preferred then 75 | local integration = get_integration(picker.module) 76 | if integration[picker.method](unpack(args)) then 77 | return true, picker.name 78 | end 79 | break 80 | end 81 | end 82 | end 83 | 84 | -- Auto-detect available pickers in order of preference 85 | for _, picker in ipairs(picker_registry) do 86 | local integration = get_integration(picker.module) 87 | if integration[picker.method](unpack(args)) then 88 | return true, picker.name 89 | end 90 | end 91 | 92 | return false, "No picker available" 93 | end 94 | 95 | --- Attempts to open a file picker based on configuration and availability 96 | --- Auto-detects available file pickers (telescope, oil, snacks, etc.) and falls back to netrw 97 | ---@param cwd string Current working directory to open picker in 98 | ---@param config table Plugin configuration table 99 | ---@return boolean success True if a picker was successfully opened 100 | ---@return string picker_name Name of the picker that was opened, or error message 101 | function Picker.open_file_picker(cwd, config) 102 | local file_picker_config = config.ui and config.ui.local_picker or {} 103 | local preferred = file_picker_config.preferred_picker or "auto" 104 | local fallback_to_netrw = file_picker_config.fallback_to_netrw ~= false -- default true 105 | 106 | -- Determine which pickers to try 107 | local pickers_to_try = FILE_PICKERS 108 | if not fallback_to_netrw then 109 | pickers_to_try = vim.tbl_filter(function(p) 110 | return p.name ~= "netrw" 111 | end, FILE_PICKERS) 112 | end 113 | 114 | return try_picker(pickers_to_try, preferred, { cwd }) 115 | end 116 | 117 | --- Attempts to open a search picker based on configuration and availability 118 | --- Auto-detects available search pickers (telescope, snacks, fzf-lua, etc.) and falls back to built-in grep 119 | ---@param cwd string Current working directory to search in 120 | ---@param pattern? string Optional search pattern to pre-populate 121 | ---@param config table Plugin configuration table 122 | ---@return boolean success True if a search picker was successfully opened 123 | ---@return string picker_name Name of the picker that was opened, or error message 124 | function Picker.open_search_picker(cwd, pattern, config) 125 | local file_picker_config = config.ui and config.ui.local_picker or {} 126 | local preferred = file_picker_config.preferred_picker or "auto" 127 | 128 | return try_picker(SEARCH_PICKERS, preferred, { cwd, pattern }) 129 | end 130 | 131 | --- Validates remote connection and returns necessary context 132 | ---@param opts? table Optional options table with 'dir' field 133 | ---@return table|nil config Plugin configuration, or nil on error 134 | ---@return table|nil active_connection Active connection table, or nil on error 135 | ---@return string|nil target_dir Target directory path, or nil on error 136 | ---@private 137 | local function validate_remote_connection(opts) 138 | opts = opts or {} 139 | local MountPoint = require("sshfs.lib.mount_point") 140 | 141 | if not MountPoint.has_active() then 142 | vim.notify("Not connected to any remote host", vim.log.levels.WARN) 143 | return nil 144 | end 145 | 146 | -- Get active connection 147 | local active_connection = MountPoint.get_active() 148 | local target_dir = opts.dir or (active_connection and active_connection.mount_path) 149 | if not target_dir then 150 | vim.notify("Invalid connection state", vim.log.levels.ERROR) 151 | return nil 152 | end 153 | 154 | -- Validate target directory 155 | local stat = vim.uv.fs_stat(target_dir) 156 | if not stat or stat.type ~= "directory" then 157 | vim.notify("Directory not accessible: " .. target_dir, vim.log.levels.ERROR) 158 | return nil 159 | end 160 | 161 | return Config.get(), active_connection, target_dir 162 | end 163 | 164 | --- Opens file picker to browse files on the active remote connection 165 | --- Auto-detects and launches preferred file picker (telescope, oil, snacks, etc.) 166 | ---@param opts? table Optional options table with 'dir' field to specify directory 167 | function Picker.browse_remote_files(opts) 168 | local AutoCommands = require("sshfs.ui.autocommands") 169 | local config, active_connection, target_dir = validate_remote_connection(opts) 170 | 171 | if not config or not active_connection or not target_dir then 172 | return 173 | end 174 | 175 | -- Try to open file picker (manual user command) 176 | AutoCommands.chdir_on_next_open(active_connection.mount_path) 177 | local success, picker_name = Picker.open_file_picker(target_dir, config) 178 | if not success then 179 | vim.notify("Failed to open " .. picker_name .. ". Please open manually.", vim.log.levels.WARN) 180 | end 181 | end 182 | 183 | --- Opens search picker to grep files on the active remote connection 184 | --- Auto-detects and launches preferred search tool (telescope, snacks, fzf-lua, etc.) 185 | ---@param pattern? string Optional search pattern to pre-populate in the search interface 186 | ---@param opts? table Optional options table with 'dir' field to specify directory 187 | function Picker.grep_remote_files(pattern, opts) 188 | local AutoCommands = require("sshfs.ui.autocommands") 189 | local config, active_connection, target_dir = validate_remote_connection(opts) 190 | 191 | if not config or not active_connection or not target_dir then 192 | return 193 | end 194 | 195 | -- Try to open search picker (manual user command) 196 | AutoCommands.chdir_on_next_open(active_connection.mount_path) 197 | local success, picker_name = Picker.open_search_picker(target_dir, pattern, config) 198 | if success then 199 | return 200 | end 201 | 202 | -- Fallback behaviour 203 | vim.cmd("tcd " .. vim.fn.fnameescape(target_dir)) 204 | if pattern and pattern ~= "" then 205 | vim.fn.setreg("/", pattern) 206 | end 207 | vim.notify( 208 | "Grep failed for: " .. picker_name .. ". Please use :grep, :vimgrep, or your preferred search tool manually.", 209 | vim.log.levels.WARN 210 | ) 211 | end 212 | 213 | --- Attempts to open a live remote grep picker 214 | --- Executes grep directly on remote server via SSH and streams results 215 | ---@param host string SSH host name 216 | ---@param mount_path string Local mount path to map remote files 217 | ---@param path? string Optional remote path to search (defaults to home) 218 | ---@param config table Plugin configuration table 219 | ---@return boolean success True if a picker was successfully opened 220 | ---@return string picker_name Name of the picker that was opened, or error message 221 | function Picker.open_live_remote_grep(host, mount_path, path, config) 222 | local live_picker_config = config.ui and config.ui.remote_picker or {} 223 | local preferred = live_picker_config.preferred_picker or "auto" 224 | 225 | -- Try preferred picker first if specified 226 | if preferred and preferred ~= "auto" then 227 | for _, picker in ipairs(LIVE_REMOTE_GREP_PICKERS) do 228 | if picker.name == preferred then 229 | local integration = get_integration(picker.module) 230 | local success = false 231 | integration[picker.method](host, mount_path, path, function(result) 232 | success = result 233 | end) 234 | if success then 235 | return true, picker.name 236 | end 237 | break 238 | end 239 | end 240 | end 241 | 242 | -- Auto-detect available pickers in order of preference 243 | for _, picker in ipairs(LIVE_REMOTE_GREP_PICKERS) do 244 | local integration = get_integration(picker.module) 245 | local success = false 246 | integration[picker.method](host, mount_path, path, function(result) 247 | success = result 248 | end) 249 | if success then 250 | return true, picker.name 251 | end 252 | end 253 | 254 | return false, "No picker available" 255 | end 256 | 257 | --- Attempts to open a live remote find picker 258 | --- Executes find directly on remote server via SSH and streams results 259 | ---@param host string SSH host name 260 | ---@param mount_path string Local mount path to map remote files 261 | ---@param path? string Optional remote path to search (defaults to home) 262 | ---@param config table Plugin configuration table 263 | ---@return boolean success True if a picker was successfully opened 264 | ---@return string picker_name Name of the picker that was opened, or error message 265 | function Picker.open_live_remote_find(host, mount_path, path, config) 266 | local live_picker_config = config.ui and config.ui.remote_picker or {} 267 | local preferred = live_picker_config.preferred_picker or "auto" 268 | 269 | -- Try preferred picker first if specified 270 | if preferred and preferred ~= "auto" then 271 | for _, picker in ipairs(LIVE_REMOTE_FIND_PICKERS) do 272 | if picker.name == preferred then 273 | local integration = get_integration(picker.module) 274 | local success = false 275 | integration[picker.method](host, mount_path, path, function(result) 276 | success = result 277 | end) 278 | if success then 279 | return true, picker.name 280 | end 281 | break 282 | end 283 | end 284 | end 285 | 286 | -- Auto-detect available pickers in order of preference 287 | for _, picker in ipairs(LIVE_REMOTE_FIND_PICKERS) do 288 | local integration = get_integration(picker.module) 289 | local success = false 290 | integration[picker.method](host, mount_path, path, function(result) 291 | success = result 292 | end) 293 | if success then 294 | return true, picker.name 295 | end 296 | end 297 | 298 | return false, "No picker available" 299 | end 300 | 301 | return Picker 302 | -------------------------------------------------------------------------------- /lua/sshfs/health.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/health.lua 2 | -- Health check for sshfs.nvim plugin 3 | -- Run with :checkhealth sshfs 4 | 5 | local Health = {} 6 | 7 | -- Compatibility layer for Neovim health API 8 | local health = vim.health or require("health") 9 | 10 | --- Check if a command exists in PATH 11 | ---@param cmd string Command name to check 12 | ---@return boolean exists True if command exists 13 | local function command_exists(cmd) 14 | return vim.fn.executable(cmd) == 1 15 | end 16 | 17 | --- Check if a file or directory exists 18 | ---@param path string Path to check 19 | ---@return boolean exists True if path exists 20 | local function path_exists(path) 21 | return vim.fn.filereadable(vim.fn.expand(path)) == 1 or vim.fn.isdirectory(vim.fn.expand(path)) == 1 22 | end 23 | 24 | --- Check if a directory is writable 25 | ---@param path string Directory path to check 26 | ---@return boolean writable True if directory is writable 27 | local function is_writable(path) 28 | local expanded = vim.fn.expand(path) 29 | if vim.fn.isdirectory(expanded) == 0 then 30 | local ok = pcall(vim.fn.mkdir, expanded, "p") 31 | if not ok then 32 | return false 33 | end 34 | end 35 | return vim.fn.filewritable(expanded) == 2 36 | end 37 | 38 | --- Check if a Neovim plugin is loaded 39 | ---@param plugin string Plugin name to check 40 | ---@return boolean loaded True if plugin is loaded 41 | local function plugin_loaded(plugin) 42 | local ok, _ = pcall(require, plugin) 43 | return ok 44 | end 45 | 46 | --- Check system dependencies (sshfs, ssh, mount) 47 | local function check_system_dependencies() 48 | health.start("System Dependencies") 49 | 50 | -- Check sshfs 51 | if command_exists("sshfs") then 52 | local handle = io.popen("sshfs --version 2>&1") 53 | if handle then 54 | local version = handle:read("*l") 55 | handle:close() 56 | health.ok("sshfs is installed: " .. (version or "version unknown")) 57 | else 58 | health.ok("sshfs is installed") 59 | end 60 | else 61 | health.error( 62 | "sshfs is not installed", 63 | "Install sshfs: 'sudo apt install sshfs' (Debian/Ubuntu) or 'brew install sshfs' (macOS)" 64 | ) 65 | end 66 | 67 | -- Check ssh 68 | if command_exists("ssh") then 69 | local handle = io.popen("ssh -V 2>&1") 70 | if handle then 71 | local version = handle:read("*l") 72 | handle:close() 73 | health.ok("ssh is installed: " .. (version or "version unknown")) 74 | else 75 | health.ok("ssh is installed") 76 | end 77 | else 78 | health.error( 79 | "ssh is not installed", 80 | "Install OpenSSH client: 'sudo apt install openssh-client' (Debian/Ubuntu)" 81 | ) 82 | end 83 | 84 | -- Check mount detection commands 85 | if command_exists("mount") then 86 | health.ok("mount command is available") 87 | else 88 | health.warn( 89 | "mount command is not available", 90 | "Mount detection may not work properly. Install util-linux package." 91 | ) 92 | end 93 | 94 | -- Check findmnt (optional, preferred on Linux for faster mount detection) 95 | if command_exists("findmnt") then 96 | health.ok("findmnt is available (preferred for mount detection on Linux)") 97 | else 98 | health.info("findmnt not found (optional, will use mount command instead)") 99 | end 100 | 101 | -- Check unmount methods (fusermount, fusermount3, umount) 102 | -- The plugin tries these in order: fusermount -> fusermount3 -> umount 103 | local unmount_methods = { 104 | { cmd = "fusermount", desc = "fusermount (FUSE2)" }, 105 | { cmd = "fusermount3", desc = "fusermount3 (FUSE3)" }, 106 | { cmd = "umount", desc = "umount (fallback)" }, 107 | } 108 | 109 | local available_unmount = {} 110 | for _, method in ipairs(unmount_methods) do 111 | if command_exists(method.cmd) then 112 | table.insert(available_unmount, method.desc) 113 | end 114 | end 115 | 116 | if #available_unmount > 0 then 117 | health.ok("Unmount methods available: " .. table.concat(available_unmount, ", ")) 118 | else 119 | health.error( 120 | "No unmount methods found (fusermount, fusermount3, or umount)", 121 | "Install fuse package: 'sudo apt install fuse3' or 'brew install macfuse'" 122 | ) 123 | end 124 | end 125 | 126 | --- Check SSH configuration 127 | local function check_ssh_config() 128 | health.start("SSH Configuration") 129 | 130 | local Config = require("sshfs.config") 131 | local config = Config.get() 132 | 133 | -- Check SSH config files 134 | local ssh_configs = config.connections.ssh_configs 135 | local found_configs = {} 136 | local missing_configs = {} 137 | 138 | for _, config_file in ipairs(ssh_configs) do 139 | local expanded = vim.fn.expand(config_file) 140 | if path_exists(expanded) then 141 | table.insert(found_configs, config_file) 142 | else 143 | table.insert(missing_configs, config_file) 144 | end 145 | end 146 | 147 | if #found_configs > 0 then 148 | health.ok("Found SSH config files: " .. table.concat(found_configs, ", ")) 149 | end 150 | 151 | if #missing_configs > 0 then 152 | health.info("Missing SSH config files (optional): " .. table.concat(missing_configs, ", ")) 153 | end 154 | 155 | if #found_configs == 0 and #missing_configs > 0 then 156 | health.warn( 157 | "No SSH config files found", 158 | "Create ~/.ssh/config to define SSH hosts. You can still use SSHConnect with host arguments." 159 | ) 160 | end 161 | 162 | -- Check SSH directory permissions 163 | local ssh_dir = vim.fn.expand("~/.ssh") 164 | if vim.fn.isdirectory(ssh_dir) == 1 then 165 | health.ok("SSH directory exists: " .. ssh_dir) 166 | 167 | -- Check socket directory 168 | local socket_dir = Config.get_socket_dir() 169 | if vim.fn.isdirectory(socket_dir) == 0 then 170 | local ok = pcall(vim.fn.mkdir, socket_dir, "p", "0700") 171 | if ok then 172 | health.ok("Created SSH socket directory with proper permissions (0700): " .. socket_dir) 173 | else 174 | health.info( 175 | "Socket directory does not exist: " 176 | .. socket_dir 177 | .. ". It will be created automatically during connection (or create manually: mkdir -p " 178 | .. socket_dir 179 | .. " && chmod 700 " 180 | .. socket_dir 181 | .. ")" 182 | ) 183 | end 184 | else 185 | health.ok("SSH socket directory exists: " .. socket_dir) 186 | end 187 | else 188 | health.warn( 189 | "SSH directory does not exist: " .. ssh_dir, 190 | "Create SSH directory with: mkdir -p ~/.ssh && chmod 700 ~/.ssh" 191 | ) 192 | end 193 | end 194 | 195 | --- Check mount configuration 196 | local function check_mount_config() 197 | health.start("Mount Configuration") 198 | 199 | local Config = require("sshfs.config") 200 | local config = Config.get() 201 | 202 | local base_dir = config.mounts.base_dir 203 | local expanded = vim.fn.expand(base_dir) 204 | 205 | -- Check if base directory exists or can be created 206 | if vim.fn.isdirectory(expanded) == 1 then 207 | health.ok("Mount base directory exists: " .. base_dir) 208 | else 209 | local ok = pcall(vim.fn.mkdir, expanded, "p") 210 | if ok then 211 | health.ok("Created mount base directory: " .. base_dir) 212 | else 213 | health.error( 214 | "Cannot create mount base directory: " .. base_dir, 215 | "Create directory manually with: mkdir -p " .. expanded 216 | ) 217 | return -- Don't check writability if we can't create it 218 | end 219 | end 220 | 221 | -- Check if directory is writable 222 | if is_writable(base_dir) then 223 | health.ok("Mount base directory is writable") 224 | else 225 | health.error( 226 | "Mount base directory is not writable: " .. base_dir, 227 | "Fix permissions with: chmod u+w " .. expanded 228 | ) 229 | end 230 | 231 | -- Info about auto_unmount setting 232 | local on_exit = config.hooks and config.hooks.on_exit or {} 233 | if on_exit.auto_unmount then 234 | health.info("Auto-unmount on exit is enabled") 235 | else 236 | health.info("Auto-unmount on exit is disabled (mounts will persist)") 237 | end 238 | if on_exit.clean_mount_folders then 239 | health.info("Mount folders will be cleaned after unmount") 240 | else 241 | health.info("Mount folders will be left on disk after unmount") 242 | end 243 | 244 | -- Info about auto_change_to_dir setting 245 | local hook_cfg = config.hooks and config.hooks.on_mount or {} 246 | if hook_cfg.auto_change_to_dir then 247 | health.info("Auto-change directory on mount is enabled") 248 | else 249 | health.info("Auto-change directory on mount is disabled") 250 | end 251 | end 252 | 253 | --- Check file picker integrations 254 | local function check_integrations() 255 | health.start("File Picker Integrations") 256 | 257 | local Config = require("sshfs.config") 258 | local config = Config.get() 259 | local preferred = config.ui.local_picker.preferred_picker 260 | 261 | health.info("Preferred picker: " .. preferred) 262 | 263 | -- List of known integrations 264 | local integrations = { 265 | { name = "telescope", plugin = "telescope" }, 266 | { name = "oil", plugin = "oil" }, 267 | { name = "snacks", plugin = "snacks" }, 268 | { name = "neo-tree", plugin = "neo-tree" }, 269 | { name = "nvim-tree", plugin = "nvim-tree" }, 270 | { name = "fzf-lua", plugin = "fzf-lua" }, 271 | { name = "mini.files", plugin = "mini.files" }, 272 | } 273 | 274 | -- Check external file managers 275 | local file_managers = { 276 | { name = "yazi", cmd = "yazi" }, 277 | { name = "lf", cmd = "lf" }, 278 | { name = "nnn", cmd = "nnn" }, 279 | { name = "ranger", cmd = "ranger" }, 280 | } 281 | 282 | local available_pickers = {} 283 | local available_managers = {} 284 | 285 | -- Check plugin-based pickers 286 | for _, integration in ipairs(integrations) do 287 | if plugin_loaded(integration.plugin) then 288 | table.insert(available_pickers, integration.name) 289 | end 290 | end 291 | 292 | -- Check external file managers 293 | for _, manager in ipairs(file_managers) do 294 | if command_exists(manager.cmd) then 295 | table.insert(available_managers, manager.name) 296 | end 297 | end 298 | 299 | -- Report available integrations 300 | if #available_pickers > 0 then 301 | health.ok("Available plugin pickers: " .. table.concat(available_pickers, ", ")) 302 | else 303 | health.info("No plugin-based file pickers detected") 304 | end 305 | 306 | if #available_managers > 0 then 307 | health.ok("Available file managers: " .. table.concat(available_managers, ", ")) 308 | else 309 | health.info("No external file managers detected") 310 | end 311 | 312 | -- Fallback to netrw 313 | if #available_pickers == 0 and #available_managers == 0 then 314 | if config.ui.local_picker.fallback_to_netrw then 315 | health.ok("Will fallback to netrw (built-in)") 316 | else 317 | health.warn( 318 | "No file pickers available and netrw fallback is disabled", 319 | "Enable netrw fallback or install a file picker plugin (telescope, oil, snacks, etc.)" 320 | ) 321 | end 322 | end 323 | 324 | -- Check if preferred picker is available 325 | if preferred ~= "auto" then 326 | local is_available = vim.tbl_contains(available_pickers, preferred) 327 | or vim.tbl_contains(available_managers, preferred) 328 | 329 | if is_available then 330 | health.ok("Preferred picker '" .. preferred .. "' is available") 331 | else 332 | health.warn( 333 | "Preferred picker '" .. preferred .. "' is not available", 334 | "Install the picker or set preferred_picker to 'auto' in config" 335 | ) 336 | end 337 | end 338 | end 339 | 340 | --- Check plugin configuration 341 | local function check_configuration() 342 | health.start("Plugin Configuration") 343 | 344 | local ok, Config = pcall(require, "sshfs.config") 345 | if not ok then 346 | health.error("Could not load sshfs.config module", "Plugin may not be installed correctly") 347 | return 348 | end 349 | 350 | local config = Config.get() 351 | 352 | -- Validate SSHFS options 353 | if config.connections.sshfs_options then 354 | health.ok("SSHFS options are configured") 355 | 356 | -- Check for recommended options 357 | if config.connections.sshfs_options.reconnect then 358 | health.ok("Auto-reconnect is enabled (recommended)") 359 | else 360 | health.info("Auto-reconnect is disabled (connections may drop)") 361 | end 362 | 363 | if config.connections.sshfs_options.compression then 364 | health.ok("Compression is enabled (recommended for slow connections)") 365 | end 366 | 367 | if config.connections.sshfs_options.ServerAliveInterval then 368 | health.ok("Keep-alive is configured (prevents connection timeouts)") 369 | else 370 | health.info("Keep-alive not configured (connections may timeout)") 371 | end 372 | end 373 | 374 | -- Check ControlMaster configuration 375 | if config.connections.control_persist then 376 | health.ok("ControlMaster persist time: " .. config.connections.control_persist) 377 | else 378 | health.warn("ControlMaster persist time not configured", "SSH terminal sessions may require re-authentication") 379 | end 380 | 381 | -- Check global_paths configuration 382 | if config.global_paths and #config.global_paths > 0 then 383 | health.ok("Global paths configured: " .. #config.global_paths .. " path(s)") 384 | else 385 | health.info("No global paths configured") 386 | end 387 | 388 | -- Check host_paths configuration 389 | if config.host_paths and next(config.host_paths) then 390 | local count = 0 391 | for _ in pairs(config.host_paths) do 392 | count = count + 1 393 | end 394 | health.ok("Custom host paths configured for " .. count .. " host(s)") 395 | else 396 | health.info("No custom host paths configured") 397 | end 398 | 399 | if 400 | not (config.global_paths and #config.global_paths > 0) and not (config.host_paths and next(config.host_paths)) 401 | then 402 | health.info("Will prompt for path on connect (no global or host-specific paths configured)") 403 | end 404 | 405 | health.ok("Plugin configuration is valid") 406 | end 407 | 408 | --- Main health check function 409 | function Health.check() 410 | check_system_dependencies() 411 | check_ssh_config() 412 | check_mount_config() 413 | check_integrations() 414 | check_configuration() 415 | 416 | health.start("Summary") 417 | health.info("Run :SSHConnect to test connection, :SSHFiles to test file browsing") 418 | health.info("See :help sshfs.nvim for documentation") 419 | end 420 | 421 | return Health 422 | -------------------------------------------------------------------------------- /lua/sshfs/api.lua: -------------------------------------------------------------------------------- 1 | -- lua/sshfs/api.lua 2 | -- Public API wrapper providing high-level functions (connect, disconnect, browse, grep, edit) 3 | 4 | local Api = {} 5 | local Config = require("sshfs.config") 6 | 7 | --- Connect to SSH host - use picker if no host provided, otherwise connect directly 8 | --- @param host table|nil SSH host object (optional) 9 | Api.connect = function(host) 10 | local Session = require("sshfs.session") 11 | if host then 12 | Session.connect(host) 13 | else 14 | local Select = require("sshfs.ui.select") 15 | Select.host(function(selected_host) 16 | if selected_host then 17 | Session.connect(selected_host) 18 | end 19 | end) 20 | end 21 | end 22 | 23 | --- Mount SSH host (alias for connect) 24 | Api.mount = function() 25 | Api.connect() 26 | end 27 | 28 | --- Disconnect from current SSH host 29 | Api.disconnect = function() 30 | local Session = require("sshfs.session") 31 | Session.disconnect() 32 | end 33 | 34 | --- Unmount from a SSH host 35 | Api.unmount = function() 36 | local Session = require("sshfs.session") 37 | local MountPoint = require("sshfs.lib.mount_point") 38 | local active_connections = MountPoint.list_active() 39 | 40 | if #active_connections == 0 then 41 | vim.notify("No active mounts to disconnect", vim.log.levels.WARN) 42 | return 43 | elseif #active_connections == 1 then 44 | Session.disconnect_from(active_connections[1]) 45 | else 46 | local Select = require("sshfs.ui.select") 47 | Select.unmount(function(selected_mount) 48 | if selected_mount then 49 | Session.disconnect_from(selected_mount) 50 | end 51 | end) 52 | end 53 | end 54 | 55 | --- Unmount all active SSH connections 56 | Api.unmount_all = function() 57 | local Session = require("sshfs.session") 58 | local MountPoint = require("sshfs.lib.mount_point") 59 | local active_connections = MountPoint.list_active() 60 | 61 | if #active_connections == 0 then 62 | vim.notify("No active mounts to disconnect", vim.log.levels.WARN) 63 | return 64 | end 65 | 66 | -- Make a copy to avoid modifying table during iteration 67 | local all_connections = vim.list_extend({}, active_connections) 68 | local disconnected_count = 0 69 | for _, connection in ipairs(all_connections) do 70 | if Session.disconnect_from(connection, true) then 71 | disconnected_count = disconnected_count + 1 72 | end 73 | end 74 | 75 | vim.notify( 76 | string.format("Disconnected from %d mount%s", disconnected_count, disconnected_count == 1 and "" or "s"), 77 | vim.log.levels.INFO 78 | ) 79 | end 80 | 81 | --- Check connection status 82 | --- @return boolean True if any active connections exist 83 | Api.has_active = function() 84 | local MountPoint = require("sshfs.lib.mount_point") 85 | return MountPoint.has_active() 86 | end 87 | 88 | --- Get current connection info 89 | --- @return table|nil Connection info or nil if none active 90 | Api.get_active = function() 91 | local MountPoint = require("sshfs.lib.mount_point") 92 | return MountPoint.get_active() 93 | end 94 | 95 | --- Edit SSH config files using native picker 96 | Api.config = function() 97 | local Select = require("sshfs.ui.select") 98 | Select.ssh_config(function(config_file) 99 | if config_file then 100 | vim.cmd("edit " .. vim.fn.fnameescape(config_file)) 101 | end 102 | end) 103 | end 104 | 105 | --- Reload SSH configuration 106 | Api.reload = function() 107 | local Session = require("sshfs.session") 108 | Session.reload() 109 | end 110 | 111 | --- Browse remote files using native file browser 112 | --- @param opts table|nil Picker options 113 | Api.find_files = function(opts) 114 | local MountPoint = require("sshfs.lib.mount_point") 115 | if not MountPoint.has_active() then 116 | vim.notify("Not connected to any remote host", vim.log.levels.WARN) 117 | return 118 | end 119 | 120 | local Picker = require("sshfs.ui.picker") 121 | Picker.browse_remote_files(opts) 122 | end 123 | 124 | --- Browse remote files - smart handling for multiple mounts 125 | --- @param opts table|nil Picker options 126 | Api.files = function(opts) 127 | local MountPoint = require("sshfs.lib.mount_point") 128 | local active_connections = MountPoint.list_active() 129 | 130 | if #active_connections == 0 then 131 | vim.notify("Not connected to any remote host", vim.log.levels.WARN) 132 | return 133 | elseif #active_connections == 1 then 134 | Api.find_files(opts) 135 | else 136 | Api.list_mounts() 137 | end 138 | end 139 | 140 | --- Search text in remote files using picker or native grep 141 | --- @param pattern string|nil Search pattern 142 | --- @param opts table|nil Picker options 143 | Api.grep = function(pattern, opts) 144 | local MountPoint = require("sshfs.lib.mount_point") 145 | local active_connections = MountPoint.list_active() 146 | 147 | if #active_connections == 0 then 148 | vim.notify("Not connected to any remote host", vim.log.levels.WARN) 149 | return 150 | elseif #active_connections == 1 then 151 | local Picker = require("sshfs.ui.picker") 152 | Picker.grep_remote_files(pattern, opts) 153 | else 154 | local Select = require("sshfs.ui.select") 155 | Select.mount(function(selected_mount) 156 | if selected_mount then 157 | local Picker = require("sshfs.ui.picker") 158 | local grep_opts = opts or {} 159 | grep_opts.dir = selected_mount.mount_path 160 | Picker.grep_remote_files(pattern, grep_opts) 161 | end 162 | end) 163 | end 164 | end 165 | 166 | --- List all active mounts and open file picker for selected mount 167 | Api.list_mounts = function() 168 | local Select = require("sshfs.ui.select") 169 | Select.mount(function(selected_mount) 170 | if selected_mount then 171 | local Picker = require("sshfs.ui.picker") 172 | local config = Config.get() 173 | local success, picker_name = Picker.open_file_picker(selected_mount.mount_path, config) 174 | 175 | if not success then 176 | vim.notify( 177 | "Could not open file picker (" .. picker_name .. ") for: " .. selected_mount.mount_path, 178 | vim.log.levels.WARN 179 | ) 180 | end 181 | end 182 | end) 183 | end 184 | 185 | --- Run a custom command on SSHFS mount (prompts for command if not provided) 186 | --- @param cmd string|nil Command to run (e.g., "edit", "tcd", "Oil"). If nil, prompts user. 187 | Api.command = function(cmd) 188 | local MountPoint = require("sshfs.lib.mount_point") 189 | MountPoint.run_command(cmd) 190 | end 191 | 192 | --- Explore SSHFS mount (opens directory as buffer, triggering file explorer) 193 | Api.explore = function() 194 | local MountPoint = require("sshfs.lib.mount_point") 195 | MountPoint.run_command("edit") 196 | end 197 | 198 | --- Change directory to an SSHFS mount 199 | Api.change_dir = function() 200 | local MountPoint = require("sshfs.lib.mount_point") 201 | MountPoint.run_command("tcd") 202 | end 203 | 204 | --- Open SSH terminal session to remote host 205 | Api.ssh_terminal = function() 206 | local Terminal = require("sshfs.ui.terminal") 207 | Terminal.open_ssh() 208 | end 209 | 210 | --- Live grep on mounted remote host (requires telescope or fzf-lua and active connection) 211 | --- Executes ripgrep/grep directly on remote server via SSH and streams results 212 | --- Note: Requires an active mount. Use :SSHConnect first. 213 | ---@param path string|nil Remote path to search (defaults to mounted remote path) 214 | Api.live_grep = function(path) 215 | local MountPoint = require("sshfs.lib.mount_point") 216 | local active_connections = MountPoint.list_active() 217 | 218 | if #active_connections == 0 then 219 | vim.notify("Not connected to any remote host. Use :SSHConnect first.", vim.log.levels.WARN) 220 | return 221 | end 222 | 223 | local function fallback_to_local_grep(connection) 224 | local Picker = require("sshfs.ui.picker") 225 | Picker.grep_remote_files(nil, { dir = connection.mount_path }) 226 | end 227 | 228 | local function execute_live_grep(connection) 229 | local Picker = require("sshfs.ui.picker") 230 | local config = Config.get() 231 | local search_path = connection.remote_path or path or "." 232 | 233 | local success, picker_name = 234 | Picker.open_live_remote_grep(connection.host, connection.mount_path, search_path, config) 235 | 236 | if not success then 237 | vim.notify( 238 | "Live grep not available: " .. picker_name .. ". Falling back to local grep on mounted path.", 239 | vim.log.levels.WARN 240 | ) 241 | return fallback_to_local_grep(connection) 242 | end 243 | end 244 | 245 | if #active_connections == 1 then 246 | execute_live_grep(active_connections[1]) 247 | else 248 | -- Multiple mounts - prompt for selection 249 | local Select = require("sshfs.ui.select") 250 | Select.mount(function(selected_mount) 251 | if selected_mount then 252 | execute_live_grep(selected_mount) 253 | end 254 | end) 255 | end 256 | end 257 | 258 | --- Live find on mounted remote host (requires telescope or fzf-lua and active connection) 259 | --- Executes fd/find directly on remote server via SSH and streams results 260 | --- Note: Requires an active mount. Use :SSHConnect first. 261 | ---@param path string|nil Remote path to search (defaults to mounted remote path) 262 | Api.live_find = function(path) 263 | local MountPoint = require("sshfs.lib.mount_point") 264 | local active_connections = MountPoint.list_active() 265 | 266 | if #active_connections == 0 then 267 | vim.notify("Not connected to any remote host. Use :SSHConnect first.", vim.log.levels.WARN) 268 | return 269 | end 270 | 271 | local function fallback_to_local_find(connection) 272 | local Picker = require("sshfs.ui.picker") 273 | local config = Config.get() 274 | local ok, picker_name = Picker.open_file_picker(connection.mount_path, config) 275 | if not ok then 276 | vim.notify( 277 | "Fallback file picker failed for " 278 | .. connection.mount_path 279 | .. " (" 280 | .. picker_name 281 | .. "). Install a supported picker.", 282 | vim.log.levels.ERROR 283 | ) 284 | end 285 | end 286 | 287 | local function execute_live_find(connection) 288 | local Picker = require("sshfs.ui.picker") 289 | local config = Config.get() 290 | local search_path = connection.remote_path or path or "." 291 | 292 | local success, picker_name = 293 | Picker.open_live_remote_find(connection.host, connection.mount_path, search_path, config) 294 | 295 | if not success then 296 | vim.notify( 297 | "Live find not available: " .. picker_name .. ". Falling back to local find on mounted path.", 298 | vim.log.levels.WARN 299 | ) 300 | return fallback_to_local_find(connection) 301 | end 302 | end 303 | 304 | if #active_connections == 1 then 305 | execute_live_find(active_connections[1]) 306 | else 307 | -- Multiple mounts - prompt for selection 308 | local Select = require("sshfs.ui.select") 309 | Select.mount(function(selected_mount) 310 | if selected_mount then 311 | execute_live_find(selected_mount) 312 | end 313 | end) 314 | end 315 | end 316 | 317 | -- TODO: Remove these after January 15th 318 | -- Deprecated aliases (kept for backward compatibility) 319 | --- @deprecated Use config instead 320 | Api.edit = Api.config 321 | 322 | --- @deprecated Use files instead 323 | Api.browse = Api.files 324 | 325 | --- @deprecated Use explore instead 326 | Api.change_to_mount_dir = Api.explore 327 | 328 | --- Creates API commands for vim api. 329 | Api.setup = function() 330 | vim.api.nvim_create_user_command("SSHConnect", function(opts) 331 | if opts.args and opts.args ~= "" then 332 | local SSHConfig = require("sshfs.lib.ssh_config") 333 | local host = SSHConfig.parse_host(opts.args) 334 | Api.connect(host) 335 | else 336 | Api.connect() 337 | end 338 | end, { nargs = "?", desc = "Remotely connect to host via picker or command as argument." }) 339 | 340 | vim.api.nvim_create_user_command("SSHConfig", function() 341 | Api.config() 342 | end, { desc = "Edit SSH config files" }) 343 | 344 | vim.api.nvim_create_user_command("SSHReload", function() 345 | Api.reload() 346 | end, { desc = "Reload SSH configuration" }) 347 | 348 | vim.api.nvim_create_user_command("SSHDisconnect", function() 349 | Api.unmount() 350 | end, { desc = "Disconnect from current SSH host" }) 351 | 352 | vim.api.nvim_create_user_command("SSHDisconnectAll", function() 353 | Api.unmount_all() 354 | end, { desc = "Disconnect from all SSH hosts" }) 355 | 356 | vim.api.nvim_create_user_command("SSHTerminal", function() 357 | Api.ssh_terminal() 358 | end, { desc = "Open SSH terminal session to remote host" }) 359 | 360 | vim.api.nvim_create_user_command("SSHFiles", function() 361 | Api.files() 362 | end, { desc = "Browse remote files" }) 363 | 364 | vim.api.nvim_create_user_command("SSHLiveFind", function(opts) 365 | local pattern = opts.args and opts.args ~= "" and opts.args or nil 366 | Api.live_find(pattern) 367 | end, { nargs = "?", desc = "Live find on mounted remote host" }) 368 | 369 | vim.api.nvim_create_user_command("SSHGrep", function(opts) 370 | local pattern = opts.args and opts.args ~= "" and opts.args or nil 371 | Api.grep(pattern) 372 | end, { nargs = "?", desc = "Search text in remote files" }) 373 | 374 | vim.api.nvim_create_user_command("SSHLiveGrep", function(opts) 375 | local pattern = opts.args and opts.args ~= "" and opts.args or nil 376 | Api.live_grep(pattern) 377 | end, { nargs = "?", desc = "Live grep on mounted remote host" }) 378 | 379 | vim.api.nvim_create_user_command("SSHExplore", function() 380 | Api.explore() 381 | end, { desc = "Explore SSH mount" }) 382 | 383 | vim.api.nvim_create_user_command("SSHCommand", function(opts) 384 | local cmd = opts.args and opts.args ~= "" and opts.args or nil 385 | Api.command(cmd) 386 | end, { nargs = "?", desc = "Run command on SSH mount (e.g., :SSHCommand tcd)" }) 387 | 388 | vim.api.nvim_create_user_command("SSHChangeDir", function() 389 | Api.change_dir() 390 | end, { desc = "Change directory to SSH mount" }) 391 | 392 | -- TODO: Delete these after January 15th 393 | -- Deprecated command aliases 394 | vim.api.nvim_create_user_command("SSHEdit", function() 395 | vim.notify("SSHEdit is deprecated. Use :SSHConfig instead.", vim.log.levels.WARN) 396 | Api.config() 397 | end, { desc = "Edit SSH config files (deprecated: use SSHConfig)" }) 398 | 399 | vim.api.nvim_create_user_command("SSHBrowse", function() 400 | vim.notify("SSHBrowse is deprecated. Use :SSHFiles instead.", vim.log.levels.WARN) 401 | Api.files() 402 | end, { desc = "Browse remote files (deprecated: use SSHFiles)" }) 403 | end 404 | 405 | return Api 406 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | SSH emoji 5 |

6 |

sshfs.nvim

7 | 8 |

9 | 10 | .nvim 11 | 12 | 13 | 14 |

15 | 16 |

17 | A fast SSHFS/SSH integration for NeoVim that works with your setup. 18 |

19 | 20 | ## 🕶️ What It Is & Why 21 | 22 | sshfs.nvim mounts hosts from your SSH config and makes them feel local. 23 | 24 | You can **browse**, **search**, **run commands**, or **open SSH terminals** across multiple mounts without changing your workflow. 25 | 26 | It stays lightweight and modern: no forced dependencies. 27 | 28 | Built for Neovim 0.10+ using the best of both `sshfs` and `ssh` in tandem with your existing tools. 29 | 30 | 31 | 32 |
33 | ✨ What's New / 🚨 Breaking Changes 34 |
35 | 36 | 37 |
38 | 🚨 v2.0 Breaking Changes 39 |

Config restructure with hooks

40 |
    41 |
  • mounts.unmount_on_exithooks.on_exit.auto_unmount
  • 42 |
  • mounts.auto_change_dir_on_mounthooks.on_mount.auto_change_to_dir
  • 43 |
  • ui.file_pickerui.local_picker
  • 44 |
  • ui.file_picker.auto_open_on_mounthooks.on_mount.auto_run
  • 45 |
46 |

SSH-first ControlMaster required

47 |
    48 |
  • Mounting now tries a non-interactive socket first, then opens an auth terminal. This passes all login responsibility to ssh which enables native support for the ssh authentication flow.
  • 49 |
50 |

sshfs_options format change

51 |
    52 |
  • connections.sshfs_options must be a key/value table (e.g., { reconnect = true, ConnectTimeout = 5 }); string arrays are ignored.
  • 53 |
      54 |
    • Strings/numbers render as key=value
    • 55 |
    • Boolean true/false enables/disables options, the value of nil also disables
    • 56 |
    57 |
58 |

Commands, API, and keymap renames (aliases will be removed after January 15, 2026)

59 |
    60 |
  • Commands: The following have beep deprecated: 61 |
      62 |
    • :SSHEdit:SSHConfig
    • 63 |
    • :SSHBrowse:SSHFiles
    • 64 |
    65 |
  • 66 |
  • API: The following have beep deprecated: 67 |
      68 |
    • editconfig
    • 69 |
    • browsefiles
    • 70 |
    • change_to_mount_dirchange_dir
    • 71 |
    72 |
  • 73 |
  • Keymaps: The following have beep deprecated: 74 |
      75 |
    • open_dirchange_dir
    • 76 |
    • openfiles
    • 77 |
    • editconfig
    • 78 |
    79 |
  • 80 |
81 | 82 |
83 | 84 | 85 |
86 | 87 | ## ✨ Features 88 | 89 | - **Uses your toolkit** – Auto-detects **snacks**, **telescope**, **fzf-lua**, **mini**, **oil**, **yazi**, **nnn**, **ranger**, **lf**, **neo-tree**, **nvim-tree**, or **netrw**. 90 | - **Auth that sticks** – ControlMaster sockets + floating auth handle keys/passwords/2FA once, then reuse for mounts, live search, terminals, git, or scp. 91 | - **Real SSH config support** – Honors Include/Match/ProxyJump and all `ssh_config` options via `ssh -G`; optional per-host default paths. 92 | - **On-mount hooks** – Auto-run find/grep/live find/live grep/terminal or your own function after connecting. 93 | - **Live remote search** – Stream `rg`/`find` over SSH (snacks, fzf-lua, telescope, mini) while keeping mounts quiet. 94 | - **Multi-mount aware** – Connect to several hosts, clean up on exit, and jump between mounts with keymaps or commands. 95 | - **Command suite** – `:SSHFiles`, `:SSHGrep`, `:SSHLiveFind/Grep`, `:SSHTerminal`, `:SSHCommand`, `:SSHChangeDir`, `:SSHConfig`, `:SSHReload`. 96 | - **Host-aware defaults** – Optional global and per-host default paths so you can skip path selection on common servers. 97 | - **Modern Neovim** – Built for 0.10+ with `vim.uv` for reliable jobs, sockets, and cleanup. 98 | 99 | ## 📋 Requirements 100 | 101 | | Software | Minimum | Notes | 102 | | ---------- | ------------- | ------------------------------------------------------------------------------------------------ | 103 | | Neovim | `>=0.10` | Requires `vim.uv` support | 104 | | sshfs | any | `sudo dnf/apt/pacman install sshfs` or `brew install sshfs` | 105 | | SSH client | any | OpenSSH with ControlMaster support (default). Socket directory created automatically if missing. | 106 | | SSH config | working hosts | Hosts come from `~/.ssh/config` | 107 | 108 | > [!NOTE] 109 | > For Mac users, see the macOS setup steps below. 110 | 111 | --- 112 | 113 | ### 🍏 macOS Setup 114 | 115 | To use **sshfs.nvim** on macOS, follow these steps: 116 | 117 | 1. **Install macFUSE** 118 | Download and install macFUSE from the official site: 119 | [https://macfuse.github.io/](https://macfuse.github.io/) 120 | 121 | 2. **Install SSHFS for macFUSE** 122 | Use the official SSHFS releases compatible with macFUSE: 123 | [https://github.com/macfuse/macfuse/wiki/File-Systems-%E2%80%90-SSHFS](https://github.com/macfuse/macfuse/wiki/File-Systems-%E2%80%90-SSHFS) 124 | 125 | ## 📦 Installation 126 | 127 | ### Lazy.nvim (Recommended) 128 | 129 | ```lua 130 | { 131 | "uhs-robert/sshfs.nvim", 132 | opts = { 133 | -- Refer to the configuration section below 134 | -- or leave empty for defaults 135 | }, 136 | } 137 | ``` 138 | 139 | ### Packer.nvim 140 | 141 | ```lua 142 | use { 143 | "uhs-robert/sshfs.nvim", 144 | config = function() 145 | require("sshfs").setup({ 146 | -- Your configuration here 147 | }) 148 | end 149 | } 150 | ``` 151 | 152 | ### vim-plug 153 | 154 | ```vim 155 | Plug 'uhs-robert/sshfs.nvim' 156 | ``` 157 | 158 | Then in your `init.lua`: 159 | 160 | ```lua 161 | require("sshfs").setup({ 162 | -- Your configuration here 163 | }) 164 | ``` 165 | 166 | ### Manual Installation 167 | 168 | 1. Clone the repository: 169 | 170 | ```bash 171 | git clone https://github.com/uhs-robert/sshfs.nvim ~/.local/share/nvim/site/pack/plugins/start/sshfs.nvim 172 | ``` 173 | 174 | 2. Add to your `init.lua`: 175 | 176 | ```lua 177 | require("sshfs").setup({ 178 | -- Your configuration here 179 | }) 180 | ``` 181 | 182 | ## ⚙️ Configuration 183 | 184 | You can optionally customize behavior by passing a config table to setup(). 185 | 186 | > [!NOTE] 187 | > Only include what you want to edit. 188 | > 189 | > Here's the full set of defaults for you to configure: 190 | 191 | ```lua 192 | require("sshfs").setup({ 193 | connections = { 194 | ssh_configs = { -- Table of ssh config file locations to use 195 | "~/.ssh/config", 196 | "/etc/ssh/ssh_config", 197 | }, 198 | -- SSHFS mount options (table of key-value pairs converted to sshfs -o arguments) 199 | -- Boolean flags: set to true to include, false/nil to omit 200 | -- String/number values: converted to key=value format 201 | sshfs_options = { 202 | reconnect = true, -- Auto-reconnect on connection loss 203 | ConnectTimeout = 5, -- Connection timeout in seconds 204 | compression = "yes", -- Enable compression 205 | ServerAliveInterval = 15, -- Keep-alive interval (15s × 3 = 45s timeout) 206 | ServerAliveCountMax = 3, -- Keep-alive message count 207 | dir_cache = "yes", -- Enable directory caching 208 | dcache_timeout = 300, -- Cache timeout in seconds 209 | dcache_max_size = 10000, -- Max cache size 210 | -- allow_other = true, -- Allow other users to access mount 211 | -- uid = "1000,gid=1000", -- Set file ownership (use string for complex values) 212 | -- follow_symlinks = true, -- Follow symbolic links 213 | }, 214 | control_persist = "10m", -- How long to keep ControlMaster connection alive after last use 215 | socket_dir = vim.fn.expand("$HOME/.ssh/sockets"), -- Directory for ControlMaster sockets 216 | }, 217 | mounts = { 218 | base_dir = vim.fn.expand("$HOME") .. "/mnt", -- where remote mounts are created 219 | }, 220 | global_paths = { 221 | -- Optionally define default mount paths for ALL hosts 222 | -- These appear as options when connecting to any host 223 | -- Examples: 224 | -- "~/.config", 225 | -- "/var/www", 226 | -- "/srv", 227 | -- "/opt", 228 | -- "/var/log", 229 | -- "/etc", 230 | -- "/tmp", 231 | -- "/usr/local", 232 | -- "/data", 233 | -- "/var/lib", 234 | }, 235 | host_paths = { 236 | -- Optionally define default mount paths for specific hosts 237 | -- These are shown in addition to global_paths 238 | -- Single path (string): 239 | -- ["my-server"] = "/var/www/html" 240 | -- 241 | -- Multiple paths (array): 242 | -- ["dev-server"] = { "/var/www", "~/projects", "/opt/app" } 243 | }, 244 | hooks = { 245 | on_exit = { 246 | auto_unmount = true, -- auto-disconnect all mounts on :q or exit 247 | clean_mount_folders = true, -- optionally clean up mount folders after disconnect 248 | }, 249 | on_mount = { 250 | auto_change_to_dir = false, -- auto-change current directory to mount point 251 | auto_run = "find", -- "find" (default), "grep", "live_find", "live_grep", "terminal", "none", or a custom function(ctx) 252 | }, 253 | }, 254 | ui = { 255 | file_picker = { 256 | preferred_picker = "auto", -- one of: "auto", "snacks", "fzf-lua", "mini", "telescope", "oil", "neo-tree", "nvim-tree", "yazi", "lf", "nnn", "ranger", "netrw" 257 | fallback_to_netrw = true, -- fallback to netrw if no picker is available 258 | netrw_command = "Explore", -- netrw command: "Explore", "Lexplore", "Sexplore", "Vexplore", "Texplore" 259 | }, 260 | remote_picker = { 261 | preferred_picker = "auto", -- one of: "auto", "snacks", "fzf-lua", "telescope", "mini" 262 | }, 263 | }, 264 | lead_prefix = "m", -- change keymap prefix (default: m) 265 | keymaps = { 266 | mount = "mm", -- creates an ssh connection and mounts via sshfs 267 | unmount = "mu", -- disconnects an ssh connection and unmounts via sshfs 268 | unmount_all = "mU", -- disconnects all ssh connections and unmounts via sshfs 269 | explore = "me", -- explore an sshfs mount using your native editor 270 | change_dir = "md", -- change dir to mount 271 | command = "mo", -- run command on mount 272 | config = "mc", -- edit ssh config 273 | reload = "mr", -- manually reload ssh config 274 | files = "mf", -- browse files using chosen picker 275 | grep = "mg", -- grep files using chosen picker 276 | terminal = "mt", -- open ssh terminal session 277 | }, 278 | }) 279 | ``` 280 | 281 | > [!TIP] 282 | > The `sshfs_args` table can accept any configuration option that applies to the `sshfs` command. You can learn more about [sshfs mount options here](https://man7.org/linux/man-pages/man1/sshfs.1.html). 283 | > 284 | > In addition, sshfs also supports a variety of options from [sftp](https://man7.org/linux/man-pages/man1/sftp.1.html) and [ssh_config](https://man7.org/linux/man-pages/man5/ssh_config.5.html). 285 | 286 | > [!NOTE] 287 | > ControlMaster sockets are stored at `~/.ssh/sockets/%C` (configurable via `connections.socket_dir`). The directory is created automatically with proper permissions (0700) during the first connection. 288 | 289 | ## 🔧 Commands 290 | 291 | - `:checkhealth sshfs` - Verify dependencies and configuration 292 | - `:SSHConnect [host]` - Mount a remote host 293 | - `:SSHDisconnect` - Unmount current host 294 | - `:SSHDisconnectAll` - Unmount all hosts 295 | - `:SSHConfig` - Edit SSH config files 296 | - `:SSHReload` - Reload SSH configuration 297 | - `:SSHFiles` - Find files with auto-detected picker 298 | - `:SSHGrep [pattern]` - Search files with auto-detected tool 299 | - `:SSHLiveFind [pattern]` - Stream remote `find`/`fd` results over SSH (snacks/fzf-lua/telescope/mini) 300 | - `:SSHLiveGrep [pattern]` - Stream remote `rg`/`grep` results over SSH (snacks/fzf-lua/telescope/mini) 301 | - `:SSHExplore` - Open file browser on mount 302 | - `:SSHChangeDir` - Change directory to mount (`tcd`) 303 | - `:SSHCommand [cmd]` - Run custom command (e.g. `Oil`, `Telescope`) 304 | - `:SSHTerminal` - Open terminal session (reuses auth) 305 | 306 | ## 🎹 Key Mapping 307 | 308 | Default keybindings under `m` (fully customizable): 309 | 310 | | Mapping | Description | 311 | | ------------ | --------------------------------- | 312 | | `mm` | Mount an SSH host | 313 | | `mu` | Unmount an active session | 314 | | `mU` | Unmount all active sessions | 315 | | `me` | Explore SSH mount via native edit | 316 | | `md` | Change dir to mount | 317 | | `mo` | Run command on mount | 318 | | `mc` | Edit SSH config | 319 | | `mr` | Reload SSH configuration | 320 | | `mf` | Find files | 321 | | `mg` | Grep files | 322 | | `mF` | Live find (remote) | 323 | | `mG` | Live grep (remote) | 324 | | `mt` | Open SSH terminal session | 325 | 326 | If [which-key.nvim](https://github.com/folke/which-key.nvim) is installed, the `m` group will be labeled with a custom icon (`󰌘`). 327 | 328 | ## 🚀 Usage 329 | 330 | 1. `:SSHConnect` — pick a host and mount path (home/root/custom/global_paths/host_paths). 331 | 2. Work from the mount: 332 | - `:SSHFiles`, `:SSHGrep`, or `:SSHChangeDir` 333 | - Live remote search: `:SSHLiveFind` / `:SSHLiveGrep` (streams over SSH, still mounted) 334 | - Terminals/commands: `:SSHTerminal`, `:SSHCommand` 335 | 3. Disconnect with `:SSHDisconnect` (or let `hooks.on_exit.auto_unmount` handle it). 336 | 337 | Auth flow: keys first, then floating terminal for passphrases/passwords/2FA; ControlMaster keeps the session alive across operations. 338 | 339 | ## 💡 Tips 340 | 341 | - **Use SSH keys** for faster connections (no password prompts) 342 | - **Configure `global_paths`** with common directories (`/var/www`, `/var/log`, `~/.config`) to have them available across all hosts 343 | - **Configure `host_paths`** for frequently-used hosts to skip path selection 344 | - **Set `preferred_picker` for local/remote pickers** to force specific file picker(s) instead of auto-detection 345 | --------------------------------------------------------------------------------