* 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.
202 lines
4.6 KiB
Luau
202 lines
4.6 KiB
Luau
--!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
|