299 lines
8.9 KiB
Lua
299 lines
8.9 KiB
Lua
local layout = require("oil.layout")
|
|
local util = require("oil.util")
|
|
|
|
---@class oil.sshConnection
|
|
---@field meta {user?: string, groups?: string[]}
|
|
---@field private term_bufnr integer
|
|
---@field private jid integer
|
|
local SSHConnection = {}
|
|
|
|
local function output_extend(agg, output)
|
|
local start = #agg
|
|
if vim.tbl_isempty(agg) then
|
|
for _, line in ipairs(output) do
|
|
line = line:gsub("\r", "")
|
|
table.insert(agg, line)
|
|
end
|
|
else
|
|
for i, v in ipairs(output) do
|
|
v = v:gsub("\r", "")
|
|
if i == 1 then
|
|
agg[#agg] = agg[#agg] .. v
|
|
else
|
|
table.insert(agg, v)
|
|
end
|
|
end
|
|
end
|
|
return start
|
|
end
|
|
|
|
---@param bufnr integer
|
|
---@param num_lines integer
|
|
---@return string[]
|
|
local function get_last_lines(bufnr, num_lines)
|
|
local end_line = vim.api.nvim_buf_line_count(bufnr)
|
|
num_lines = math.min(num_lines, end_line)
|
|
local lines = {}
|
|
while end_line > 0 and #lines < num_lines do
|
|
local need_lines = num_lines - #lines
|
|
lines = vim.list_extend(
|
|
vim.api.nvim_buf_get_lines(bufnr, end_line - need_lines, end_line, false),
|
|
lines
|
|
)
|
|
while not vim.tbl_isempty(lines) and lines[#lines]:match("^%s*$") do
|
|
table.remove(lines)
|
|
end
|
|
end_line = end_line - need_lines
|
|
end
|
|
return lines
|
|
end
|
|
|
|
---@param url oil.sshUrl
|
|
---@return string[]
|
|
function SSHConnection.create_ssh_command(url)
|
|
local host = url.host
|
|
if url.user then
|
|
host = url.user .. "@" .. host
|
|
end
|
|
local command = {
|
|
"ssh",
|
|
host,
|
|
}
|
|
if url.port then
|
|
table.insert(command, "-p")
|
|
table.insert(command, url.port)
|
|
end
|
|
return command
|
|
end
|
|
|
|
---@param url oil.sshUrl
|
|
---@return oil.sshConnection
|
|
function SSHConnection.new(url)
|
|
local command = SSHConnection.create_ssh_command(url)
|
|
vim.list_extend(command, {
|
|
"/bin/bash",
|
|
"--norc",
|
|
"-c",
|
|
-- HACK: For some reason in my testing if I just have "echo READY" it doesn't appear, but if I echo
|
|
-- anything prior to that, it *will* appear. The first line gets swallowed.
|
|
"echo '_make_newline_'; echo '===READY==='; exec /bin/bash --norc",
|
|
})
|
|
local term_bufnr = vim.api.nvim_create_buf(false, true)
|
|
local self = setmetatable({
|
|
meta = {},
|
|
commands = {},
|
|
connected = false,
|
|
connection_error = nil,
|
|
term_bufnr = term_bufnr,
|
|
}, {
|
|
__index = SSHConnection,
|
|
})
|
|
|
|
local term_id
|
|
local mode = vim.api.nvim_get_mode().mode
|
|
util.run_in_fullscreen_win(term_bufnr, function()
|
|
term_id = vim.api.nvim_open_term(term_bufnr, {
|
|
on_input = function(_, _, _, data)
|
|
pcall(vim.api.nvim_chan_send, self.jid, data)
|
|
end,
|
|
})
|
|
end)
|
|
self.term_id = term_id
|
|
vim.api.nvim_chan_send(term_id, string.format("ssh %s\r\n", url.host))
|
|
util.hack_around_termopen_autocmd(mode)
|
|
|
|
-- If it takes more than 2 seconds to connect, pop open the terminal
|
|
vim.defer_fn(function()
|
|
if not self.connected and not self.connection_error then
|
|
self:open_terminal()
|
|
end
|
|
end, 2000)
|
|
self._stdout = {}
|
|
local jid = vim.fn.jobstart(command, {
|
|
pty = true, -- This is require for interactivity
|
|
on_stdout = function(j, output)
|
|
pcall(vim.api.nvim_chan_send, self.term_id, table.concat(output, "\r\n"))
|
|
local new_i_start = output_extend(self._stdout, output)
|
|
self:_handle_output(new_i_start)
|
|
end,
|
|
on_exit = function(j, code)
|
|
pcall(
|
|
vim.api.nvim_chan_send,
|
|
self.term_id,
|
|
string.format("\r\n[Process exited %d]\r\n", code)
|
|
)
|
|
-- Defer to allow the deferred terminal output handling to kick in first
|
|
vim.defer_fn(function()
|
|
if code == 0 then
|
|
self:_set_connection_error("SSH connection terminated gracefully")
|
|
else
|
|
self:_set_connection_error(
|
|
'Unknown SSH error\nTo see more, run :lua require("oil.adapters.ssh").open_terminal()'
|
|
)
|
|
end
|
|
end, 20)
|
|
end,
|
|
})
|
|
local exe = command[1]
|
|
if jid == 0 then
|
|
self:_set_connection_error(string.format("Passed invalid arguments to '%s'", exe))
|
|
elseif jid == -1 then
|
|
self:_set_connection_error(string.format("'%s' is not executable", exe))
|
|
else
|
|
self.jid = jid
|
|
end
|
|
self:run("whoami", function(err, lines)
|
|
if err then
|
|
vim.notify(string.format("Error fetching ssh connection user: %s", err), vim.log.levels.WARN)
|
|
else
|
|
assert(lines)
|
|
self.meta.user = vim.trim(table.concat(lines, ""))
|
|
end
|
|
end)
|
|
self:run("groups", function(err, lines)
|
|
if err then
|
|
vim.notify(
|
|
string.format("Error fetching ssh connection user groups: %s", err),
|
|
vim.log.levels.WARN
|
|
)
|
|
else
|
|
assert(lines)
|
|
self.meta.groups = vim.split(table.concat(lines, ""), "%s+", { trimempty = true })
|
|
end
|
|
end)
|
|
|
|
return self
|
|
end
|
|
|
|
---@param err string
|
|
function SSHConnection:_set_connection_error(err)
|
|
if self.connection_error then
|
|
return
|
|
end
|
|
self.connection_error = err
|
|
local commands = self.commands
|
|
self.commands = {}
|
|
for _, cmd in ipairs(commands) do
|
|
cmd.cb(err)
|
|
end
|
|
end
|
|
|
|
function SSHConnection:_handle_output(start_i)
|
|
if not self.connected then
|
|
for i = start_i, #self._stdout - 1 do
|
|
local line = self._stdout[i]
|
|
if line == "===READY===" then
|
|
if self.term_winid then
|
|
if vim.api.nvim_win_is_valid(self.term_winid) then
|
|
vim.api.nvim_win_close(self.term_winid, true)
|
|
end
|
|
self.term_winid = nil
|
|
end
|
|
self.connected = true
|
|
self._stdout = util.tbl_slice(self._stdout, i + 1)
|
|
self:_handle_output(1)
|
|
self:_consume()
|
|
return
|
|
end
|
|
end
|
|
else
|
|
for i = start_i, #self._stdout - 1 do
|
|
---@type string
|
|
local line = self._stdout[i]
|
|
if line:match("^===BEGIN===%s*$") then
|
|
self._stdout = util.tbl_slice(self._stdout, i + 1)
|
|
self:_handle_output(1)
|
|
return
|
|
end
|
|
-- We can't be as strict with the matching (^$) because since we're using a pty the stdout and
|
|
-- stderr can be interleaved. If the command had an error, the stderr may interfere with a
|
|
-- clean print of the done line.
|
|
local exit_code = line:match("===DONE%((%d+)%)===")
|
|
if exit_code then
|
|
local output = util.tbl_slice(self._stdout, 1, i - 1)
|
|
local cb = self.commands[1].cb
|
|
self._stdout = util.tbl_slice(self._stdout, i + 1)
|
|
if exit_code == "0" then
|
|
cb(nil, output)
|
|
else
|
|
cb(exit_code .. ": " .. table.concat(output, "\n"), output)
|
|
end
|
|
table.remove(self.commands, 1)
|
|
self:_handle_output(1)
|
|
self:_consume()
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
local function check_last_line()
|
|
local last_lines = get_last_lines(self.term_bufnr, 1)
|
|
local last_line = last_lines[1]
|
|
if last_line:match("^Are you sure you want to continue connecting") then
|
|
self:open_terminal()
|
|
elseif last_line:match("Password:%s*$") then
|
|
self:open_terminal()
|
|
elseif last_line:match(": Permission denied %(.+%)%.") then
|
|
self:_set_connection_error(last_line:match(": (Permission denied %(.+%).)"))
|
|
elseif last_line:match("^ssh: .*Connection refused%s*$") then
|
|
self:_set_connection_error("Connection refused")
|
|
elseif last_line:match("^Connection to .+ closed by remote host.%s*$") then
|
|
self:_set_connection_error("Connection closed by remote host")
|
|
end
|
|
end
|
|
-- We have to defer this so the terminal buffer has time to update
|
|
vim.defer_fn(check_last_line, 10)
|
|
end
|
|
|
|
function SSHConnection:open_terminal()
|
|
if self.term_winid and vim.api.nvim_win_is_valid(self.term_winid) then
|
|
vim.api.nvim_set_current_win(self.term_winid)
|
|
return
|
|
end
|
|
local min_width = 120
|
|
local min_height = 20
|
|
local total_height = layout.get_editor_height()
|
|
local width = math.min(min_width, vim.o.columns - 2)
|
|
local height = math.min(min_height, total_height - 3)
|
|
local row = math.floor((total_height - height) / 2)
|
|
local col = math.floor((vim.o.columns - width) / 2)
|
|
self.term_winid = vim.api.nvim_open_win(self.term_bufnr, true, {
|
|
relative = "editor",
|
|
width = width,
|
|
height = height,
|
|
row = row,
|
|
col = col,
|
|
style = "minimal",
|
|
border = "rounded",
|
|
})
|
|
vim.cmd.startinsert()
|
|
end
|
|
|
|
---@param command string
|
|
---@param callback fun(err: nil|string, lines: nil|string[])
|
|
function SSHConnection:run(command, callback)
|
|
if self.connection_error then
|
|
callback(self.connection_error)
|
|
else
|
|
table.insert(self.commands, { cmd = command, cb = callback })
|
|
self:_consume()
|
|
end
|
|
end
|
|
|
|
function SSHConnection:_consume()
|
|
if self.connected and not vim.tbl_isempty(self.commands) then
|
|
local cmd = self.commands[1]
|
|
if not cmd.running then
|
|
cmd.running = true
|
|
vim.api.nvim_chan_send(
|
|
self.jid,
|
|
-- HACK: Sleep briefly to help reduce stderr/stdout interleaving.
|
|
-- I want to find a way to flush the stderr before the echo DONE, but haven't yet.
|
|
-- This was causing issues when ls directory that doesn't exist (b/c ls prints error)
|
|
'echo "===BEGIN==="; ' .. cmd.cmd .. '; CODE=$?; sleep .01; echo "===DONE($CODE)==="\r'
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
return SSHConnection
|