feat(ssh-launcher): add /ssh launcher for SSH config hosts (#138)
* feat(ssh-launcher): add /ssh launcher for SSH config hosts Provide a launcher provider that reads ~/.ssh/config, lists host aliases with fuzzy search, and opens an SSH session in the terminal on activate. * docs(ssh-launcher): update store thumbnail Replace the plugin card image with the final SSH Launcher artwork, blur server IP addresses, and fix the displayed category tags. * fix(ssh-launcher): parse SSH config directives case-insensitively Match Host, Hostname, and User keywords regardless of casing so lowercase blocks are included, consistent with OpenSSH behavior.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
--!nonstrict
|
||||
|
||||
local cachedHosts = nil
|
||||
|
||||
local function trim(s)
|
||||
return noctalia.string.trim(s or "")
|
||||
end
|
||||
|
||||
local function shellQuote(s)
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function getConfigPath()
|
||||
local configured = noctalia.getConfig("config_path")
|
||||
if type(configured) == "string" and configured ~= "" then
|
||||
return noctalia.expandPath(configured)
|
||||
end
|
||||
return noctalia.expandPath("~/.ssh/config")
|
||||
end
|
||||
|
||||
local function getMaxResults()
|
||||
local n = noctalia.getConfig("max_results")
|
||||
return (type(n) == "number" and n > 0) and math.floor(n) or 30
|
||||
end
|
||||
|
||||
local function statusRow(title, subtitle, glyph)
|
||||
return { id = "", title = title, subtitle = subtitle, glyph = glyph }
|
||||
end
|
||||
|
||||
local function isValidHostAlias(alias)
|
||||
return alias ~= "*" and not alias:match("[*?]")
|
||||
end
|
||||
|
||||
local function hostSubtitle(host)
|
||||
if host.user and host.hostname then
|
||||
return host.user .. "@" .. host.hostname
|
||||
end
|
||||
return host.hostname or host.user or ""
|
||||
end
|
||||
|
||||
local function parseSshConfig(content)
|
||||
local entries = {}
|
||||
local current = nil
|
||||
|
||||
local function flushBlock()
|
||||
if not current or #current.aliases == 0 then
|
||||
return
|
||||
end
|
||||
local subtitle = hostSubtitle(current)
|
||||
for _, alias in current.aliases do
|
||||
table.insert(entries, { alias = alias, subtitle = subtitle })
|
||||
end
|
||||
end
|
||||
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
local trimmed = trim(line)
|
||||
if trimmed ~= "" and not trimmed:match("^#") then
|
||||
local key, value = trimmed:match("^(%S+)%s+(.+)$")
|
||||
if key and value then
|
||||
key = key:lower()
|
||||
if key == "host" then
|
||||
flushBlock()
|
||||
current = { aliases = {}, hostname = nil, user = nil }
|
||||
for alias in value:gmatch("%S+") do
|
||||
if isValidHostAlias(alias) then
|
||||
table.insert(current.aliases, alias)
|
||||
end
|
||||
end
|
||||
elseif current then
|
||||
if key == "hostname" then
|
||||
current.hostname = trim(value)
|
||||
elseif key == "user" then
|
||||
current.user = trim(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
flushBlock()
|
||||
return entries
|
||||
end
|
||||
|
||||
local function loadHosts()
|
||||
local content = noctalia.readFile(getConfigPath())
|
||||
if type(content) ~= "string" or content == "" then
|
||||
return nil
|
||||
end
|
||||
return parseSshConfig(content)
|
||||
end
|
||||
|
||||
local function launchTerminal(cmd)
|
||||
local term = trim(noctalia.getConfig("terminal") or "")
|
||||
if term == "" then
|
||||
return noctalia.runInTerminal(cmd)
|
||||
end
|
||||
|
||||
local first = term:match("^%S+") or term
|
||||
local bin = first:match("([^/]+)$") or first
|
||||
local separator = (bin == "gnome-terminal" or bin == "kgx" or bin == "ptyxis") and "--" or "-e"
|
||||
return noctalia.runAsync(term .. " " .. separator .. " sh -lc " .. shellQuote(cmd))
|
||||
end
|
||||
|
||||
local function fuzzyScoreForHost(filter, host)
|
||||
for _, text in { host.alias, host.subtitle } do
|
||||
if text ~= "" then
|
||||
local score = noctalia.fuzzyScore(filter, text)
|
||||
if score then
|
||||
return score
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function makeRows(hosts, filter)
|
||||
local rows = {}
|
||||
local limit = getMaxResults()
|
||||
|
||||
for _, host in hosts do
|
||||
local score = filter == "" and nil or fuzzyScoreForHost(filter, host)
|
||||
if filter == "" or score ~= nil then
|
||||
table.insert(rows, {
|
||||
id = host.alias,
|
||||
title = host.alias,
|
||||
subtitle = host.subtitle ~= "" and host.subtitle or nil,
|
||||
glyph = "terminal",
|
||||
score = score,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if filter == "" then
|
||||
table.sort(rows, function(a, b)
|
||||
return a.title:lower() < b.title:lower()
|
||||
end)
|
||||
while #rows > limit do
|
||||
table.remove(rows)
|
||||
end
|
||||
end
|
||||
|
||||
return rows
|
||||
end
|
||||
|
||||
local function showResults(query, hosts, filter)
|
||||
local rows = makeRows(hosts, filter)
|
||||
if #rows == 0 then
|
||||
launcher.setResults(query, {
|
||||
statusRow(
|
||||
noctalia.tr("no-hosts-found"),
|
||||
filter ~= "" and noctalia.tr("filter-empty", { filter = filter }) or noctalia.tr("config-empty"),
|
||||
"terminal"
|
||||
),
|
||||
})
|
||||
else
|
||||
launcher.setResults(query, rows)
|
||||
end
|
||||
end
|
||||
|
||||
function onQuery(query)
|
||||
local filter = trim(query)
|
||||
|
||||
if not noctalia.commandExists("ssh") then
|
||||
launcher.setResults(query, {
|
||||
statusRow(noctalia.tr("err-no-ssh"), noctalia.tr("err-no-ssh-subtitle"), "triangle-alert"),
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if cachedHosts then
|
||||
showResults(query, cachedHosts, filter)
|
||||
return
|
||||
end
|
||||
|
||||
launcher.setResults(query, {
|
||||
statusRow(noctalia.tr("loading"), noctalia.tr("loading-subtitle"), "loader"),
|
||||
})
|
||||
|
||||
local hosts = loadHosts()
|
||||
if not hosts then
|
||||
launcher.setResults(query, {
|
||||
statusRow(noctalia.tr("config-missing"), getConfigPath(), "file-x"),
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
cachedHosts = hosts
|
||||
showResults(query, hosts, filter)
|
||||
end
|
||||
|
||||
function onActivate(id)
|
||||
if id == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local extra = trim(noctalia.getConfig("extra_args") or "")
|
||||
local cmd = "ssh " .. shellQuote(id) .. (extra ~= "" and (" " .. extra) or "")
|
||||
|
||||
if not launchTerminal(cmd) then
|
||||
noctalia.notifyError("SSH Launcher", noctalia.tr("err-terminal"))
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user