From d6e27e776bc6eff55a0e7d18b0698bc1f5e8aea5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 12 Sep 2020 06:36:41 +0530 Subject: [PATCH] Start work on the remote_file kitten Easy access to files over SSH --- kittens/remote_file/__init__.py | 0 kittens/remote_file/main.py | 173 ++++++++++++++++++++++++++++++++ kittens/ssh/main.py | 44 +++++++- kitty/boss.py | 3 + kitty/cli_stub.py | 5 +- kitty/config.py | 2 + kitty/window.py | 19 +++- 7 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 kittens/remote_file/__init__.py create mode 100644 kittens/remote_file/main.py diff --git a/kittens/remote_file/__init__.py b/kittens/remote_file/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kittens/remote_file/main.py b/kittens/remote_file/main.py new file mode 100644 index 000000000..37030524b --- /dev/null +++ b/kittens/remote_file/main.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +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) diff --git a/kittens/ssh/main.py b/kittens/ssh/main.py index edb9337cc..215cf40dc 100644 --- a/kittens/ssh/main.py +++ b/kittens/ssh/main.py @@ -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'} diff --git a/kitty/boss.py b/kitty/boss.py index 28f3f56d7..558266778 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -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 diff --git a/kitty/cli_stub.py b/kitty/cli_stub.py index c42c74ee5..ed655952d 100644 --- a/kitty/cli_stub.py +++ b/kitty/cli_stub.py @@ -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') diff --git a/kitty/config.py b/kitty/config.py index bd4a276cf..27a351355 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -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, } diff --git a/kitty/window.py b/kitty/window.py index 6ddfe934b..02fc0725d 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -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)