Start work on the remote_file kitten
Easy access to files over SSH
This commit is contained in:
parent
e6839b45e3
commit
d6e27e776b
0
kittens/remote_file/__init__.py
Normal file
0
kittens/remote_file/__init__.py
Normal file
173
kittens/remote_file/main.py
Normal file
173
kittens/remote_file/main.py
Normal 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)
|
||||||
@ -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'}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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')
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user