Remote file kitten: Integrate with the ssh kitten
This commit is contained in:
parent
d3656bf7e9
commit
1b4cf1fea7
@ -49,6 +49,10 @@ Detailed list of changes
|
|||||||
|
|
||||||
- Fix a regression in the previous release that caused mouse move events to be incorrectly reported as drag events even when a button is not pressed (:iss:`4992`)
|
- Fix a regression in the previous release that caused mouse move events to be incorrectly reported as drag events even when a button is not pressed (:iss:`4992`)
|
||||||
|
|
||||||
|
- remote file kitten: Integrate with the ssh kitten for improved performance
|
||||||
|
and robustness. Re-uses the control master connection of the ssh kitten to
|
||||||
|
avoid round-trip latency.
|
||||||
|
|
||||||
- Fix tab selection when closing a new tab not correct in some scenarios (:iss:`4987`)
|
- Fix tab selection when closing a new tab not correct in some scenarios (:iss:`4987`)
|
||||||
|
|
||||||
- A new action :ac:`open_url` to open the specified URL (:pull:`5004`)
|
- A new action :ac:`open_url` to open the specified URL (:pull:`5004`)
|
||||||
|
|||||||
@ -27,13 +27,16 @@ supports hyperlinks.
|
|||||||
.. versionadded:: 0.19.0
|
.. versionadded:: 0.19.0
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Nested SSH sessions are not supported. The kitten will always try to copy
|
For best results, use this kitten with the :doc:`ssh kitten <./ssh>`.
|
||||||
remote files from the first SSH host. This is because there is no way for
|
Otherwise, nested SSH sessions are not supported. The kitten will always try to copy
|
||||||
|
remote files from the first SSH host. This is because, without the ssh
|
||||||
|
kitten, there is no way for
|
||||||
|kitty| to detect and follow a nested SSH session robustly. Use the
|
|kitty| to detect and follow a nested SSH session robustly. Use the
|
||||||
:doc:`transfer` kitten for such situations.
|
:doc:`transfer` kitten for such situations.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
If you have not setup automatic password-less SSH access, then, when editing
|
If you have not setup automatic password-less SSH access, and are not using
|
||||||
|
the ssh kitten, then, when editing
|
||||||
starts you will be asked to enter your password just once, thereafter the SSH
|
starts you will be asked to enter your password just once, thereafter the SSH
|
||||||
connection will be re-used.
|
connection will be re-used.
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,9 @@ from ..tui.operations import (
|
|||||||
from ..tui.utils import get_key_press
|
from ..tui.utils import get_key_press
|
||||||
|
|
||||||
|
|
||||||
|
is_ssh_kitten_sentinel = '!#*&$#($ssh-kitten)(##$'
|
||||||
|
|
||||||
|
|
||||||
def key(x: str) -> str:
|
def key(x: str) -> str:
|
||||||
return styled(x, bold=True, fg='green')
|
return styled(x, bold=True, fg='green')
|
||||||
|
|
||||||
@ -53,10 +56,9 @@ The data used to connect over ssh.
|
|||||||
|
|
||||||
|
|
||||||
def show_error(msg: str) -> None:
|
def show_error(msg: str) -> None:
|
||||||
print(styled(msg, fg='red'))
|
print(styled(msg, fg='red'), file=sys.stderr)
|
||||||
print()
|
print()
|
||||||
print('Press any key to quit')
|
print('Press any key to quit', flush=True)
|
||||||
sys.stdout.flush()
|
|
||||||
with raw_mode():
|
with raw_mode():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@ -112,10 +114,20 @@ class ControlMaster:
|
|||||||
self.remote_path = remote_path
|
self.remote_path = remote_path
|
||||||
self.dest = dest
|
self.dest = dest
|
||||||
self.tdir = ''
|
self.tdir = ''
|
||||||
|
self.last_error_log = ''
|
||||||
self.cmd_prefix = cmd = [
|
self.cmd_prefix = cmd = [
|
||||||
conn_data.binary, '-o', f'ControlPath=~/.ssh/kitty-master-{os.getpid()}-%r@%h:%p',
|
conn_data.binary, '-o', f'ControlPath=~/.ssh/kitty-master-{os.getpid()}-%r@%h:%p',
|
||||||
'-o', 'TCPKeepAlive=yes', '-o', 'ControlPersist=yes'
|
'-o', 'TCPKeepAlive=yes', '-o', 'ControlPersist=yes'
|
||||||
]
|
]
|
||||||
|
self.is_ssh_kitten = conn_data.binary is is_ssh_kitten_sentinel
|
||||||
|
if self.is_ssh_kitten:
|
||||||
|
del cmd[:]
|
||||||
|
self.batch_cmd_prefix = cmd
|
||||||
|
sk_cmdline = json.loads(conn_data.identity_file)
|
||||||
|
while '-t' in sk_cmdline:
|
||||||
|
sk_cmdline.remove('-t')
|
||||||
|
cmd.extend(sk_cmdline[:-2])
|
||||||
|
else:
|
||||||
if conn_data.port:
|
if conn_data.port:
|
||||||
cmd.extend(['-p', str(conn_data.port)])
|
cmd.extend(['-p', str(conn_data.port)])
|
||||||
if conn_data.identity_file:
|
if conn_data.identity_file:
|
||||||
@ -130,6 +142,7 @@ class ControlMaster:
|
|||||||
raise Exception(f'The ssh command: {shlex.join(cmd)} failed with exit code {p.returncode} and output: {out}')
|
raise Exception(f'The ssh command: {shlex.join(cmd)} failed with exit code {p.returncode} and output: {out}')
|
||||||
|
|
||||||
def __enter__(self) -> 'ControlMaster':
|
def __enter__(self) -> 'ControlMaster':
|
||||||
|
if not self.is_ssh_kitten:
|
||||||
self.check_call(
|
self.check_call(
|
||||||
self.cmd_prefix + ['-o', 'ControlMaster=auto', '-fN', self.conn_data.hostname])
|
self.cmd_prefix + ['-o', 'ControlMaster=auto', '-fN', self.conn_data.hostname])
|
||||||
self.check_call(
|
self.check_call(
|
||||||
@ -140,6 +153,7 @@ class ControlMaster:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *a: Any) -> None:
|
def __exit__(self, *a: Any) -> None:
|
||||||
|
if not self.is_ssh_kitten:
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
self.batch_cmd_prefix + ['-O', 'exit', self.conn_data.hostname],
|
self.batch_cmd_prefix + ['-O', 'exit', self.conn_data.hostname],
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL
|
||||||
@ -149,12 +163,16 @@ class ControlMaster:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_alive(self) -> bool:
|
def is_alive(self) -> bool:
|
||||||
|
if self.is_ssh_kitten:
|
||||||
|
return True
|
||||||
return subprocess.Popen(
|
return subprocess.Popen(
|
||||||
self.batch_cmd_prefix + ['-O', 'check', self.conn_data.hostname],
|
self.batch_cmd_prefix + ['-O', 'check', self.conn_data.hostname],
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL
|
||||||
).wait() == 0
|
).wait() == 0
|
||||||
|
|
||||||
def check_hostname_matches(self) -> bool:
|
def check_hostname_matches(self) -> bool:
|
||||||
|
if self.is_ssh_kitten:
|
||||||
|
return True
|
||||||
cp = subprocess.run(self.batch_cmd_prefix + [self.conn_data.hostname, 'hostname', '-f'], stdout=subprocess.PIPE,
|
cp = subprocess.run(self.batch_cmd_prefix + [self.conn_data.hostname, 'hostname', '-f'], stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
||||||
if cp.returncode == 0:
|
if cp.returncode == 0:
|
||||||
@ -181,21 +199,35 @@ class ControlMaster:
|
|||||||
return response == 'y'
|
return response == 'y'
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def show_error(self, msg: str) -> None:
|
||||||
|
if self.last_error_log:
|
||||||
|
print(self.last_error_log, file=sys.stderr)
|
||||||
|
self.last_error_log = ''
|
||||||
|
show_error(msg)
|
||||||
|
|
||||||
def download(self) -> bool:
|
def download(self) -> bool:
|
||||||
|
cmdline = self.batch_cmd_prefix + [self.conn_data.hostname, 'cat', self.remote_path]
|
||||||
with open(self.dest, 'wb') as f:
|
with open(self.dest, 'wb') as f:
|
||||||
return subprocess.run(
|
cp = subprocess.run(cmdline, stdout=f, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL)
|
||||||
self.batch_cmd_prefix + [self.conn_data.hostname, 'cat', self.remote_path],
|
if cp.returncode != 0:
|
||||||
stdout=f, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL
|
self.last_error_log = f'The command: {shlex.join(cmdline)} failed\n' + cp.stderr.decode()
|
||||||
).returncode == 0
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def upload(self, suppress_output: bool = True) -> bool:
|
def upload(self, suppress_output: bool = True) -> bool:
|
||||||
cmd_prefix = self.cmd_prefix if suppress_output else self.batch_cmd_prefix
|
cmd_prefix = self.cmd_prefix if suppress_output else self.batch_cmd_prefix
|
||||||
cmd = cmd_prefix + [self.conn_data.hostname, 'cat', '>', self.remote_path]
|
cmd = cmd_prefix + [self.conn_data.hostname, 'cat', '>', self.remote_path]
|
||||||
if not suppress_output:
|
if not suppress_output:
|
||||||
print(' '.join(map(shlex.quote, cmd)))
|
print(shlex.join(cmd))
|
||||||
redirect = subprocess.DEVNULL if suppress_output else None
|
|
||||||
with open(self.dest, 'rb') as f:
|
with open(self.dest, 'rb') as f:
|
||||||
return subprocess.run(cmd, stdout=redirect, stderr=redirect, stdin=f).returncode == 0
|
if suppress_output:
|
||||||
|
cp = subprocess.run(cmd, stdin=f, capture_output=True)
|
||||||
|
if cp.returncode == 0:
|
||||||
|
return True
|
||||||
|
self.last_error_log = f'The command: {shlex.join(cmd)} failed\n' + cp.stdout.decode()
|
||||||
|
else:
|
||||||
|
return subprocess.run(cmd, stdin=f).returncode == 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
Result = Optional[str]
|
Result = Optional[str]
|
||||||
@ -278,11 +310,15 @@ def save_as(conn_data: SSHConnectionData, remote_path: str, cli_opts: RemoteFile
|
|||||||
with ControlMaster(conn_data, remote_path, cli_opts, dest=dest) as master:
|
with ControlMaster(conn_data, remote_path, cli_opts, dest=dest) as master:
|
||||||
if master.check_hostname_matches():
|
if master.check_hostname_matches():
|
||||||
if not master.download():
|
if not master.download():
|
||||||
show_error('Failed to copy file from remote machine')
|
master.show_error('Failed to copy file from remote machine')
|
||||||
|
|
||||||
|
|
||||||
def handle_action(action: str, cli_opts: RemoteFileCLIOptions) -> Result:
|
def handle_action(action: str, cli_opts: RemoteFileCLIOptions) -> Result:
|
||||||
conn_data = SSHConnectionData(*json.loads(cli_opts.ssh_connection_data or ''))
|
cli_data = json.loads(cli_opts.ssh_connection_data or '')
|
||||||
|
if cli_data and cli_data[0] == is_ssh_kitten_sentinel:
|
||||||
|
conn_data = SSHConnectionData(is_ssh_kitten_sentinel, cli_data[-1], -1, identity_file=json.dumps(cli_data[1:]))
|
||||||
|
else:
|
||||||
|
conn_data = SSHConnectionData(*cli_data)
|
||||||
remote_path = cli_opts.path or ''
|
remote_path = cli_opts.path or ''
|
||||||
if action == 'open':
|
if action == 'open':
|
||||||
print('Opening', cli_opts.path, 'from', cli_opts.hostname)
|
print('Opening', cli_opts.path, 'from', cli_opts.hostname)
|
||||||
@ -291,7 +327,7 @@ def handle_action(action: str, cli_opts: RemoteFileCLIOptions) -> Result:
|
|||||||
if master.check_hostname_matches():
|
if master.check_hostname_matches():
|
||||||
if master.download():
|
if master.download():
|
||||||
return dest
|
return dest
|
||||||
show_error('Failed to copy file from remote machine')
|
master.show_error('Failed to copy file from remote machine')
|
||||||
elif action == 'edit':
|
elif action == 'edit':
|
||||||
print('Editing', cli_opts.path, 'from', cli_opts.hostname)
|
print('Editing', cli_opts.path, 'from', cli_opts.hostname)
|
||||||
editor = get_editor()
|
editor = get_editor()
|
||||||
@ -299,7 +335,7 @@ def handle_action(action: str, cli_opts: RemoteFileCLIOptions) -> Result:
|
|||||||
if not master.check_hostname_matches():
|
if not master.check_hostname_matches():
|
||||||
return None
|
return None
|
||||||
if not master.download():
|
if not master.download():
|
||||||
show_error(f'Failed to download {remote_path}')
|
master.show_error(f'Failed to download {remote_path}')
|
||||||
return None
|
return None
|
||||||
mtime = os.path.getmtime(master.dest)
|
mtime = os.path.getmtime(master.dest)
|
||||||
print(reset_terminal(), end='', flush=True)
|
print(reset_terminal(), end='', flush=True)
|
||||||
@ -314,9 +350,9 @@ def handle_action(action: str, cli_opts: RemoteFileCLIOptions) -> Result:
|
|||||||
print(reset_terminal(), end='', flush=True)
|
print(reset_terminal(), end='', flush=True)
|
||||||
if master.is_alive:
|
if master.is_alive:
|
||||||
if not master.upload(suppress_output=False):
|
if not master.upload(suppress_output=False):
|
||||||
show_error(f'Failed to upload {remote_path}')
|
master.show_error(f'Failed to upload {remote_path}')
|
||||||
else:
|
else:
|
||||||
show_error(f'Failed to upload {remote_path}, SSH master process died')
|
master.show_error(f'Failed to upload {remote_path}, SSH master process died')
|
||||||
elif action == 'save':
|
elif action == 'save':
|
||||||
print('Saving', cli_opts.path, 'from', cli_opts.hostname)
|
print('Saving', cli_opts.path, 'from', cli_opts.hostname)
|
||||||
save_as(conn_data, remote_path, cli_opts)
|
save_as(conn_data, remote_path, cli_opts)
|
||||||
|
|||||||
@ -829,7 +829,16 @@ class Window:
|
|||||||
set_clipboard_string(url)
|
set_clipboard_string(url)
|
||||||
|
|
||||||
def handle_remote_file(self, netloc: str, remote_path: str) -> None:
|
def handle_remote_file(self, netloc: str, remote_path: str) -> None:
|
||||||
|
from .utils import SSHConnectionData
|
||||||
from kittens.ssh.main import get_connection_data
|
from kittens.ssh.main import get_connection_data
|
||||||
|
from kittens.remote_file.main import is_ssh_kitten_sentinel
|
||||||
|
args = self.ssh_kitten_cmdline()
|
||||||
|
conn_data: Union[None, List[str], SSHConnectionData] = None
|
||||||
|
if args:
|
||||||
|
ssh_cmdline = sorted(self.child.foreground_processes, key=lambda p: p['pid'])[-1]['cmdline'] or ['']
|
||||||
|
idx = ssh_cmdline.index('--')
|
||||||
|
conn_data = [is_ssh_kitten_sentinel] + list(ssh_cmdline[:idx + 2])
|
||||||
|
else:
|
||||||
args = self.child.foreground_cmdline
|
args = self.child.foreground_cmdline
|
||||||
conn_data = get_connection_data(args, self.child.foreground_cwd or self.child.current_cwd or '')
|
conn_data = get_connection_data(args, self.child.foreground_cwd or self.child.current_cwd or '')
|
||||||
if conn_data is None:
|
if conn_data is None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user