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 shlex
import subprocess import subprocess
import sys import sys
from typing import List, NoReturn, Set, Tuple from contextlib import suppress
from typing import List, NamedTuple, NoReturn, Optional, Set, Tuple
SHELL_SCRIPT = '''\ SHELL_SCRIPT = '''\
#!/bin/sh #!/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) 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]: def parse_ssh_args(args: List[str]) -> Tuple[List[str], List[str], bool]:
boolean_ssh_args, other_ssh_args = get_ssh_cli() boolean_ssh_args, other_ssh_args = get_ssh_cli()
passthrough_args = {'-' + x for x in 'Nnf'} passthrough_args = {'-' + x for x in 'Nnf'}

View File

@ -930,6 +930,9 @@ class Boss:
kargs = shlex.split(cmdline) if cmdline else [] kargs = shlex.split(cmdline) if cmdline else []
self._run_kitten(kitten, kargs) 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: 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) output = self.get_output(source_window, num_lines=None)
from kittens.runner import deserialize from kittens.runner import deserialize

View File

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

View File

@ -789,6 +789,7 @@ class KittyCommonOpts(TypedDict):
select_by_word_characters: str select_by_word_characters: str
open_url_with: List[str] open_url_with: List[str]
url_prefixes: Tuple[str, ...] url_prefixes: Tuple[str, ...]
editor: str
def common_opts_as_dict(opts: Optional[OptionsStub] = None) -> KittyCommonOpts: 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, 'select_by_word_characters': opts.select_by_word_characters,
'open_url_with': opts.open_url_with, 'open_url_with': opts.open_url_with,
'url_prefixes': opts.url_prefixes, '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: def open_url(self, url: str, hyperlink_id: int) -> None:
if hyperlink_id: if hyperlink_id:
from urllib.parse import urlparse from urllib.parse import unquote, urlparse
try: try:
purl = urlparse(url) purl = urlparse(url)
except Exception: except Exception:
@ -505,13 +505,22 @@ class Window:
hostname = '' hostname = ''
remote_hostname = purl.netloc.partition(':')[0] remote_hostname = purl.netloc.partition(':')[0]
if remote_hostname and remote_hostname != hostname: 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 return
get_boss().open_url(url) get_boss().open_url(url)
def handle_remote_file(self, netloc: str, remote_path: str) -> None: 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: def focus_changed(self, focused: bool) -> None:
if self.destroyed: if self.destroyed:
@ -544,8 +553,8 @@ class Window:
def on_bell(self) -> None: def on_bell(self) -> None:
if self.opts.command_on_bell and self.opts.command_on_bell != ['none']: if self.opts.command_on_bell and self.opts.command_on_bell != ['none']:
import subprocess
import shlex import shlex
import subprocess
env = self.child.final_env env = self.child.final_env
env['KITTY_CHILD_CMDLINE'] = ' '.join(map(shlex.quote, self.child.cmdline)) 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) 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 self.current_marker_spec = key
def set_marker(self, spec: Union[str, Sequence[str]]) -> None: 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 from .marks import marker_from_spec
if isinstance(spec, str): if isinstance(spec, str):
func, (ftype, spec_, flags) = toggle_marker('toggle_marker', spec) func, (ftype, spec_, flags) = toggle_marker('toggle_marker', spec)