Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ require("sshfs").setup({
## 🔧 Commands

- `:checkhealth sshfs` - Verify dependencies and configuration
- `:SSHConnect [host]` - Mount a remote host
- `:SSHConnect [host]` - Mount a remote host. Accepts an ssh_config alias or a host that is in no config at all, e.g. `:SSHConnect user@example.com:/srv -p 2222`. Without an argument the picker offers "Enter host manually..." for the same thing.
- `:SSHTest [host]` - Test SSH resolution and authentication without mounting
- `:SSHDisconnect` - Unmount current host
- `:SSHDisconnectAll` - Unmount all hosts
Expand Down Expand Up @@ -337,7 +337,7 @@ If [which-key.nvim](https://github.com/folke/which-key.nvim) is installed, the `

## 🚀 Usage

1. `:SSHConnect` — pick a host and mount path (home/root/custom/global_paths/host_paths).
1. `:SSHConnect` — pick a host (or enter one manually) and mount path (home/root/custom/global_paths/host_paths).
2. Work from the mount:
- `:SSHFiles`, `:SSHGrep`, or `:SSHChangeDir`
- Live remote search: `:SSHLiveFind` / `:SSHLiveGrep` (streams over SSH, still mounted)
Expand Down
12 changes: 10 additions & 2 deletions lua/sshfs/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,11 @@ Api.setup = function()
vim.api.nvim_create_user_command("SSHConnect", function(opts)
if opts.args and opts.args ~= "" then
local SSHConfig = require("sshfs.lib.ssh_config")
local host = SSHConfig.parse_host(opts.args)
local host = SSHConfig.resolve_input(opts.args)
if not host then
vim.notify("Could not read a host from: " .. opts.args, vim.log.levels.ERROR)
return
end
Api.connect(host)
else
Api.connect()
Expand All @@ -383,7 +387,11 @@ Api.setup = function()
vim.api.nvim_create_user_command("SSHTest", function(opts)
if opts.args and opts.args ~= "" then
local SSHConfig = require("sshfs.lib.ssh_config")
local host = SSHConfig.parse_host(opts.args)
local host = SSHConfig.resolve_input(opts.args)
if not host then
vim.notify("Could not read a host from: " .. opts.args, vim.log.levels.ERROR)
return
end
Api.test(host)
else
Api.test()
Expand Down
18 changes: 18 additions & 0 deletions lua/sshfs/lib/ssh_config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,24 @@ function SSHConfig.parse_host(command)
return host
end

--- Resolve a typed host string through ssh -G, keeping anything typed explicitly
--- @param input string e.g. "user@example.com:/srv -p 2222" or a config alias
--- @return table|nil host Host object, or nil when no host name was given
function SSHConfig.resolve_input(input)
local parsed = SSHConfig.parse_host(vim.trim(input or ""))
if not parsed.name or parsed.name == "" then return nil end

local resolved = SSHConfig.get_host_config(parsed.name)
if not resolved then return parsed end

-- A typed user or port is not in ssh_config, so it outranks the resolved value.
return vim.tbl_extend("force", resolved, {
user = parsed.user or resolved.user,
port = parsed.port or resolved.port,
path = parsed.path,
})
end

--- Clear the hosts and resolved_configs caches to force re-parsing on next get_hosts() call
function SSHConfig.refresh()
CACHE.hosts = nil
Expand Down
40 changes: 32 additions & 8 deletions lua/sshfs/ui/select.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

local Select = {}

local MANUAL_ENTRY = "Enter host manually..."

--- Get active mounts or display warning.
---@return table|nil Active mounts array or nil if none found
local function get_active_mounts_or_warn()
Expand Down Expand Up @@ -90,35 +92,57 @@ function Select.unmount(callback)
end)
end

--- Prompt for a host that is not in ssh_config, e.g. "user@example.com"
---@param callback function Callback invoked with the resolved host object
function Select.host_input(callback)
vim.ui.input({ prompt = "Connect to (user@host): " }, function(input)
if not input or vim.trim(input) == "" then return end

local host = require("sshfs.lib.ssh_config").resolve_input(input)
if not host then
vim.notify("Could not read a host from: " .. input, vim.log.levels.ERROR)
return
end
callback(host)
end)
end

--- Host selection for choosing an SSH Host to connect to.
---@param callback function Callback invoked with selected host object
function Select.host(callback)
local SSHConfig = require("sshfs.lib.ssh_config")
local hosts = SSHConfig.get_hosts()

-- Nothing to pick from means the manual prompt is the only way through.
if not hosts or #hosts == 0 then
vim.notify("No SSH hosts found in configuration", vim.log.levels.WARN)
Select.host_input(callback)
return
end

-- Sort hosts alphabetically
local host_list = vim.deepcopy(hosts)
table.sort(host_list)
table.insert(host_list, MANUAL_ENTRY)

vim.ui.select(host_list, {
prompt = "Select SSH host to connect:",
format_item = function(item)
return item
end,
}, function(choice)
if choice then
local host, err = SSHConfig.get_host_config(choice)
if not host then
vim.notify("Failed to resolve SSH config: " .. (err or "Unknown error"), vim.log.levels.ERROR)
return
end
callback(host)
if not choice then return end

if choice == MANUAL_ENTRY then
Select.host_input(callback)
return
end

local host, err = SSHConfig.get_host_config(choice)
if not host then
vim.notify("Failed to resolve SSH config: " .. (err or "Unknown error"), vim.log.levels.ERROR)
return
end
callback(host)
end)
end

Expand Down
170 changes: 170 additions & 0 deletions tests/manual_host_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
-- tests/manual_host_spec.lua
-- Connecting to a host that is not in ssh_config, typed as `user@host`
--
-- A typed user or port cannot come from ssh_config, so it must outrank
-- whatever `ssh -G` reports for the same name.

--- Load SSHConfig against a faked `ssh -G`
--- @param resolved table|nil Key/value lines ssh -G reports, or nil to fail resolution
local function load_config(resolved)
stub.reload()
require("sshfs.config").setup({})

stub.system(function()
if not resolved then return "", 255 end

local lines = {}
for key, value in pairs(resolved) do
table.insert(lines, key .. " " .. value)
end
table.sort(lines)
return table.concat(lines, "\n") .. "\n", 0
end)

return require("sshfs.lib.ssh_config")
end

describe("SSHConfig.resolve_input", function()
it("keeps a typed user over the one ssh -G reports", function()
local SSHConfig = load_config({ user = "localuser", port = "22", hostname = "10.0.0.5" })

local host = SSHConfig.resolve_input("remoteuser@10.0.0.5")
stub.restore_all()

expect.eq(host.user, "remoteuser", "the typed user is the whole point of typing it")
expect.eq(host.name, "10.0.0.5")
end)

it("keeps a typed port over the resolved one", function()
local SSHConfig = load_config({ user = "deploy", port = "22", hostname = "example.com" })

local host = SSHConfig.resolve_input("example.com -p 2222")
stub.restore_all()

expect.eq(host.port, "2222")
end)

it("falls back to the resolved values when nothing is typed", function()
local SSHConfig = load_config({ user = "deploy", port = "2222", hostname = "10.0.0.5" })

local host = SSHConfig.resolve_input("alias")
stub.restore_all()

expect.eq(host.user, "deploy")
expect.eq(host.port, "2222")
expect.eq(host.hostname, "10.0.0.5", "an alias must still gain its resolved config")
end)

it("still returns a usable host when ssh -G cannot resolve it", function()
local SSHConfig = load_config(nil)

local host = SSHConfig.resolve_input("remoteuser@10.0.0.5")
stub.restore_all()

expect.truthy(host, "an unresolvable host must still be connectable")
expect.eq(host.user, "remoteuser")
expect.eq(host.name, "10.0.0.5")
end)

it("carries a typed remote path", function()
local SSHConfig = load_config({ user = "deploy", port = "22", hostname = "example.com" })

local host = SSHConfig.resolve_input("deploy@example.com:/srv/app")
stub.restore_all()

expect.eq(host.path, "/srv/app")
end)

it("returns nil for an empty input", function()
local SSHConfig = load_config({ user = "deploy", port = "22" })

expect.is_nil(SSHConfig.resolve_input(""))
expect.is_nil(SSHConfig.resolve_input(" "))
stub.restore_all()
end)
end)

describe("manual host entry", function()
it("prompts when ssh_config lists no hosts", function()
load_config({ user = "deploy", port = "22", hostname = "10.0.0.5" })
package.loaded["sshfs.lib.ssh_config"].get_hosts = function()
return {}
end

local prompted = false
stub.set("ui.input", function(_, on_input)
prompted = true
on_input("remoteuser@10.0.0.5")
end)

local selected
require("sshfs.ui.select").host(function(host)
selected = host
end)
stub.restore_all()

expect.truthy(prompted, "an empty ssh_config must not be a dead end")
expect.eq(selected.user, "remoteuser")
end)

it("offers a manual entry alongside the configured hosts", function()
load_config({ user = "deploy", port = "22", hostname = "10.0.0.5" })
package.loaded["sshfs.lib.ssh_config"].get_hosts = function()
return { "alpha", "beta" }
end

local offered
stub.set("ui.select", function(items)
offered = items
end)

require("sshfs.ui.select").host(function() end)
stub.restore_all()

expect.eq(#offered, 3, "the two hosts plus a manual entry")
expect.eq(offered[#offered], "Enter host manually...")
end)

it("prompts when the manual entry is chosen", function()
load_config({ user = "deploy", port = "22", hostname = "10.0.0.5" })
package.loaded["sshfs.lib.ssh_config"].get_hosts = function()
return { "alpha" }
end

stub.set("ui.select", function(items, _, on_choice)
on_choice(items[#items])
end)
stub.set("ui.input", function(_, on_input)
on_input("remoteuser@10.0.0.5 -p 2222")
end)

local selected
require("sshfs.ui.select").host(function(host)
selected = host
end)
stub.restore_all()

expect.truthy(selected, "choosing manual entry must reach the callback")
expect.eq(selected.user, "remoteuser")
expect.eq(selected.port, "2222")
end)

it("does nothing when the prompt is cancelled", function()
load_config({ user = "deploy", port = "22" })
package.loaded["sshfs.lib.ssh_config"].get_hosts = function()
return {}
end

stub.set("ui.input", function(_, on_input)
on_input(nil)
end)

local called = false
require("sshfs.ui.select").host(function()
called = true
end)
stub.restore_all()

expect.eq(called, false, "cancelling must not connect anywhere")
end)
end)
Loading