Start work on the remote_file kitten

Easy access to files over SSH
This commit is contained in:
Kovid Goyal 2020-09-12 06:36:41 +05:30
parent e6839b45e3
commit d6e27e776b
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
7 changed files with 239 additions and 7 deletions

View File

173
kittens/remote_file/main.py Normal file
View File

@ -0,0 +1,173 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
import json
import os
import subprocess
import sys
import tempfile
from contextlib import suppress
from typing import List, Optional
from kitty.cli import parse_args
from kitty.cli_stub import RemoteFileCLIOptions
from kitty.typing import BossType
from kitty.utils import command_for_open, open_cmd
from ..ssh.main import SSHConnectionData
from ..tui.handler import result_handler
from ..tui.operations import clear_screen, faint, set_cursor_visible, styled
def option_text() -> str:
return '''\
--mode -m
choices=ask,edit
default=ask
Which mode to operate in.
--path -p
Path to the remote file.
--hostname -h
Hostname of the remote host.
--ssh-connection-data
The data used to connect over ssh.
'''
def show_error(msg: str) -> None:
print(styled(msg, fg='red'))
print()
print('Press any key to exit...')
import tty
sys.stdout.flush()
tty.setraw(sys.stdin.fileno())
try:
while True:
try:
q = sys.stdin.buffer.read(1)
if q:
break
except (KeyboardInterrupt, EOFError):
break
finally:
tty.setcbreak(sys.stdin.fileno())
sys.stdout.flush()
def ask_action(opts: RemoteFileCLIOptions) -> str:
print('What would you like to do with the remote file on {}:'.format(styled(opts.hostname or 'unknown', bold=True, fg='magenta')))
print(styled(opts.path or '', fg='yellow', fg_intense=True))
print()
def key(x: str) -> str:
return styled(x, bold=True, fg='green')
def help_text(x: str) -> str:
return faint(x)
print('{}dit the file'.format(key('E')))
print(help_text('The file will be downloaded and opened in an editor. Any changes you save will'
' be automatically sent back to the remote machine'))
print()
print('{}pen the file'.format(key('O')))
print(help_text('The file will be downloaded and opened by the default open program'))
print()
print('{}ancel'.format(key('C')))
print()
import tty
sys.stdout.flush()
tty.setraw(sys.stdin.fileno())
response = 'c'
try:
while True:
q = sys.stdin.buffer.read(1)
if q:
if q in b'\x1b\x03':
break
with suppress(Exception):
response = q.decode('utf-8').lower()
if response in 'ceo':
break
except (KeyboardInterrupt, EOFError):
pass
finally:
tty.setcbreak(sys.stdin.fileno())
sys.stdout.flush()
return {'e': 'edit', 'o': 'open'}.get(response, 'cancel')
def simple_copy_command(conn_data: SSHConnectionData, path: str) -> List[str]:
cmd = [conn_data.binary]
if conn_data.port:
cmd += ['-p', str(conn_data.port)]
cmd += [conn_data.hostname, 'cat', path]
return cmd
def save_output(cmd: List[str], dest_path: str) -> bool:
with open(dest_path, 'wb') as f:
cp = subprocess.run(cmd, stdout=f)
return cp.returncode == 0
Result = Optional[str]
def main(args: List[str]) -> Result:
msg = 'Ask the user what to do with the remote file'
try:
cli_opts, items = parse_args(args[1:], option_text, '', msg, 'kitty remote_file', result_class=RemoteFileCLIOptions)
except SystemExit as e:
if e.code != 0:
print(e.args[0])
input('Press enter to quit...')
raise SystemExit(e.code)
print(set_cursor_visible(False), end='')
try:
action = ask_action(cli_opts)
finally:
print(set_cursor_visible(True), clear_screen(), end='')
try:
return handle_action(action, cli_opts)
except Exception:
import traceback
traceback.print_exc()
show_error('Failed with unhandled exception')
def handle_action(action: str, cli_opts: RemoteFileCLIOptions) -> Result:
conn_data = SSHConnectionData(*json.loads(cli_opts.ssh_connection_data or ''))
remote_path = cli_opts.path or ''
if action == 'open':
print('Opening', cli_opts.path, 'from', cli_opts.hostname)
cmd = simple_copy_command(conn_data, remote_path)
dest = os.path.join(tempfile.mkdtemp(), os.path.basename(remote_path))
if save_output(cmd, dest):
return dest
show_error('Failed to copy file from remote machine')
elif action == 'edit':
print('Editing', cli_opts.path, 'from', cli_opts.hostname)
@result_handler()
def handle_result(args: List[str], data: Result, target_window_id: int, boss: BossType) -> None:
if data:
cmd = command_for_open(boss.opts.open_url_with)
open_cmd(cmd, data)
if __name__ == '__main__':
main(sys.argv)

View File

@ -7,7 +7,8 @@ import re
import shlex
import subprocess
import sys
from typing import List, NoReturn, Set, Tuple
from contextlib import suppress
from typing import List, NamedTuple, NoReturn, Optional, Set, Tuple
SHELL_SCRIPT = '''\
#!/bin/sh
@ -60,6 +61,47 @@ def get_ssh_cli() -> Tuple[Set[str], Set[str]]:
return set('-' + x for x in boolean_ssh_args), set('-' + x for x in other_ssh_args)
class SSHConnectionData(NamedTuple):
binary: str
hostname: str
port: Optional[int] = None
def get_connection_data(args: List[str]) -> Optional[SSHConnectionData]:
boolean_ssh_args, other_ssh_args = get_ssh_cli()
found_ssh = ''
port: Optional[int] = None
expecting_port = False
expecting_option_val = False
for i, arg in enumerate(args):
if not found_ssh:
if os.path.basename(arg).lower() in ('ssh', 'ssh.exe'):
found_ssh = arg
continue
if arg.startswith('-') and not expecting_option_val:
if arg in boolean_ssh_args:
continue
if arg.startswith('-p'):
if arg[2:].isdigit():
with suppress(Exception):
port = int(arg[2:])
elif arg == '-p':
expecting_port = True
expecting_option_val = True
continue
if expecting_option_val:
if expecting_port:
with suppress(Exception):
port = int(arg)
expecting_port = False
expecting_option_val = False
continue
return SSHConnectionData(found_ssh, arg, port)
def parse_ssh_args(args: List[str]) -> Tuple[List[str], List[str], bool]:
boolean_ssh_args, other_ssh_args = get_ssh_cli()
passthrough_args = {'-' + x for x in 'Nnf'}

View File

@ -930,6 +930,9 @@ class Boss:
kargs = shlex.split(cmdline) if cmdline else []
self._run_kitten(kitten, kargs)
def run_kitten(self, kitten: str, *args: str) -> None:
self._run_kitten(kitten, args)
def on_kitten_finish(self, target_window_id: str, end_kitten: Callable, source_window: Window) -> None:
output = self.get_output(source_window, num_lines=None)
from kittens.runner import deserialize

View File

@ -12,7 +12,7 @@ class CLIOptions:
LaunchCLIOptions = AskCLIOptions = ClipboardCLIOptions = DiffCLIOptions = CLIOptions
HintsCLIOptions = IcatCLIOptions = PanelCLIOptions = ResizeCLIOptions = CLIOptions
ErrorCLIOptions = UnicodeCLIOptions = RCOptions = CLIOptions
ErrorCLIOptions = UnicodeCLIOptions = RCOptions = RemoteFileCLIOptions = CLIOptions
def generate_stub() -> None:
@ -35,6 +35,9 @@ def generate_stub() -> None:
from kittens.ask.main import option_text
do(option_text(), 'AskCLIOptions')
from kittens.remote_file.main import option_text
do(option_text(), 'RemoteFileCLIOptions')
from kittens.clipboard.main import OPTIONS
do(OPTIONS(), 'ClipboardCLIOptions')

View File

@ -789,6 +789,7 @@ class KittyCommonOpts(TypedDict):
select_by_word_characters: str
open_url_with: List[str]
url_prefixes: Tuple[str, ...]
editor: str
def common_opts_as_dict(opts: Optional[OptionsStub] = None) -> KittyCommonOpts:
@ -798,4 +799,5 @@ def common_opts_as_dict(opts: Optional[OptionsStub] = None) -> KittyCommonOpts:
'select_by_word_characters': opts.select_by_word_characters,
'open_url_with': opts.open_url_with,
'url_prefixes': opts.url_prefixes,
'editor': opts.editor,
}

View File

@ -491,7 +491,7 @@ class Window:
def open_url(self, url: str, hyperlink_id: int) -> None:
if hyperlink_id:
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse
try:
purl = urlparse(url)
except Exception:
@ -505,13 +505,22 @@ class Window:
hostname = ''
remote_hostname = purl.netloc.partition(':')[0]
if remote_hostname and remote_hostname != hostname:
self.handle_remote_file(purl.netloc, purl.path)
self.handle_remote_file(purl.netloc, unquote(purl.path))
return
get_boss().open_url(url)
def handle_remote_file(self, netloc: str, remote_path: str) -> None:
pass
from kittens.ssh.main import get_connection_data
args = self.child.foreground_cmdline
conn_data = get_connection_data(args)
if conn_data is None:
get_boss().show_error('Could not handle remote file', 'No SSH connection data found in: {args}')
return
get_boss().run_kitten(
'remote_file', '--hostname', netloc.partition(':')[0], '--path', remote_path,
'--ssh-connection-data', json.dumps(conn_data)
)
def focus_changed(self, focused: bool) -> None:
if self.destroyed:
@ -544,8 +553,8 @@ class Window:
def on_bell(self) -> None:
if self.opts.command_on_bell and self.opts.command_on_bell != ['none']:
import subprocess
import shlex
import subprocess
env = self.child.final_env
env['KITTY_CHILD_CMDLINE'] = ' '.join(map(shlex.quote, self.child.cmdline))
subprocess.Popen(self.opts.command_on_bell, env=env, cwd=self.child.foreground_cwd)
@ -893,7 +902,7 @@ class Window:
self.current_marker_spec = key
def set_marker(self, spec: Union[str, Sequence[str]]) -> None:
from .config import toggle_marker, parse_marker_spec
from .config import parse_marker_spec, toggle_marker
from .marks import marker_from_spec
if isinstance(spec, str):
func, (ftype, spec_, flags) = toggle_marker('toggle_marker', spec)