diff --git a/kittens/remote_file/actions.go b/kittens/remote_file/actions.go index 725dfa6ea..3910920b3 100644 --- a/kittens/remote_file/actions.go +++ b/kittens/remote_file/actions.go @@ -167,6 +167,10 @@ func edit_loop(master *ControlMaster, editor []string) error { return err } mtime := st.ModTime() + // Mirrors main.py handle_action's edit branch: reset the terminal (clear + // whatever the ask-menu/hostname-prompt phases drew) right before handing + // the screen to the user's $EDITOR, and again once it exits. + reset_terminal() argv := append(append([]string{}, editor...), master.Dest) cmd := exec.Command(argv[0], argv[1:]...) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr @@ -178,6 +182,7 @@ func edit_loop(master *ControlMaster, editor []string) error { for { select { case <-done: + reset_terminal() if master.IsAlive() { if err := master.Upload(false); err != nil { return fmt.Errorf("failed to upload %s: %w", master.remote_path, err) diff --git a/kittens/remote_file/main.go b/kittens/remote_file/main.go new file mode 100644 index 000000000..e7182c1e9 --- /dev/null +++ b/kittens/remote_file/main.go @@ -0,0 +1,396 @@ +// License: GPLv3 Copyright: 2026, Kovid Goyal, + +package remote_file + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/kovidgoyal/kitty/kittens/choose_files" + "github.com/kovidgoyal/kitty/tools/cli" + "github.com/kovidgoyal/kitty/tools/cli/markup" + "github.com/kovidgoyal/kitty/tools/tui" + "github.com/kovidgoyal/kitty/tools/tui/loop" + "github.com/kovidgoyal/kitty/tools/tui/readline" + "github.com/kovidgoyal/kitty/tools/utils" +) + +var _ = fmt.Print + +// reset_terminal performs a full terminal reset (RIS), exactly mirroring +// kittens/tui/operations.py:reset_terminal()'s escape sequence +// ('\033]\033\\\033c'). Python's remote_file calls this between UI phases +// (after the ask menu, around hostname-mismatch prompts, around the +// overwrite-prompt, and around the $EDITOR invocation) to clear whatever was +// drawn by the previous phase/loop before drawing the next; we mirror those +// call sites exactly so the two implementations look the same on screen. +func reset_terminal() { + fmt.Print("\x1b]\x1b\\\x1bc") +} + +// get_key_press shows `draw` and waits for one of the runes in allowed. +// Returns deflt on Esc/Ctrl+C. Mirrors kittens/tui/utils.py:get_key_press. +func get_key_press(draw func(lp *loop.Loop, ctx *markup.Context), allowed, deflt string) (ans string, err error) { + lp, err := loop.NewForSimpleInteraction() + if err != nil { + return "", err + } + ctx := markup.New(true) + ans = deflt + lp.OnInitialize = func() (string, error) { + lp.SetCursorVisible(false) + draw(lp, ctx) + return "", nil + } + lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error { + text = strings.ToLower(text) + if allowed != "" && strings.Contains(allowed, text) { + ans = text + lp.Quit(0) + } + return nil + } + lp.OnKeyEvent = func(e *loop.KeyEvent) error { + if e.MatchesPressOrRepeat("esc") || e.MatchesPressOrRepeat("ctrl+c") { + e.Handled = true + lp.Quit(1) + } + return nil + } + err = lp.Run() + lp.KillIfSignalled() + return ans, err +} + +// wait_for_any_key blocks until the user presses any single key. Unlike +// get_key_press it does not filter by an allowed set, matching Python's +// show_error which breaks on the first byte read regardless of its value. +func wait_for_any_key() error { + lp, err := loop.NewForSimpleInteraction() + if err != nil { + return err + } + lp.OnInitialize = func() (string, error) { + lp.SetCursorVisible(false) + return "", nil + } + lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error { + lp.Quit(0) + return nil + } + lp.OnKeyEvent = func(e *loop.KeyEvent) error { + e.Handled = true + lp.Quit(0) + return nil + } + err = lp.Run() + lp.KillIfSignalled() + return err +} + +func show_error(msg string) { + ctx := markup.New(true) + fmt.Fprintln(os.Stderr, ctx.Err(msg)) + fmt.Println() + fmt.Println("Press any key to quit") + _ = wait_for_any_key() +} + +func ask_action(opts *Options) (string, error) { + draw := func(lp *loop.Loop, ctx *markup.Context) { + hostname := opts.Hostname + if hostname == "" { + hostname = "unknown" + } + lp.Println("What would you like to do with the remote file on " + ctx.Magenta(hostname) + ":") + lp.Println(ctx.Yellow(opts.Path)) + lp.Println() + lp.Println(ctx.Green("E") + "dit the file") + lp.Println(lp.SprintStyled("dim", "The file will be downloaded and opened in an editor. Any changes you save will be automatically sent back to the remote machine")) + lp.Println() + lp.Println(ctx.Green("O") + "pen the file") + lp.Println(lp.SprintStyled("dim", "The file will be downloaded and opened by the default open program")) + lp.Println() + lp.Println(ctx.Green("S") + "ave the file") + lp.Println(lp.SprintStyled("dim", "The file will be downloaded to a destination you select")) + lp.Println() + lp.Println(ctx.Green("C") + "ancel") + } + response, err := get_key_press(draw, "ceos", "c") + if err != nil { + return "cancel", err + } + return map[string]string{"e": "edit", "o": "open", "s": "save"}[response], nil +} + +// check_hostname_matches prompts the user when the remote hostname does not +// match the hyperlink hostname; mirrors main.py ControlMaster.check_hostname_matches. +func check_hostname_matches(master *ControlMaster, cli_hostname string) (bool, error) { + if master.conn.IsSSHKitten { + return true, nil + } + q := master.remote_hostname() + if q == "" || hostname_matches(cli_hostname, q) { + return true, nil + } + reset_terminal() + draw := func(lp *loop.Loop, ctx *markup.Context) { + lp.Println("The remote hostname " + ctx.Green(q) + " does not match the") + lp.Println("hostname in the hyperlink " + ctx.Err(cli_hostname)) + lp.Println("This indicates that kitty has not connected to the correct remote machine.") + lp.Println("This can happen, for example, when using nested SSH sessions.") + lp.Printf("The hostname kitty used to connect was: %s", ctx.Yellow(master.conn.Hostname)) + if master.conn.Port > 0 { + lp.Printf(" with port: %d", master.conn.Port) + } + lp.Println() + lp.Println() + lp.Println("Do you want to continue anyway?") + lp.Println(ctx.Green("Y") + "es\t" + ctx.Err("N") + "o") + } + response, err := get_key_press(draw, "yn", "n") + reset_terminal() + return response == "y", err +} + +// get_save_path reads a single line of input for the destination path, +// with filename tab-completion, mirroring kittens/tui/path_completer.py's +// PathCompleter/get_path. aborted is true on Ctrl+C/EOF, matching Python's +// catch of KeyboardInterrupt/EOFError in save_as. +func get_save_path(prompt string) (result string, aborted bool, err error) { + lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors) + if err != nil { + return "", false, err + } + rl := readline.New(lp, readline.RlInit{Prompt: prompt, Completer: choose_files.FilePromptCompleter(nil)}) + lp.OnInitialize = func() (string, error) { rl.Start(); return "", nil } + lp.OnFinalize = func() string { rl.End(); return "" } + lp.OnResumeFromStop = func() error { rl.Start(); return nil } + lp.OnResize = rl.OnResize + lp.OnKeyEvent = func(event *loop.KeyEvent) error { + if event.MatchesPressOrRepeat("ctrl+c") { + aborted = true + lp.Quit(0) + return nil + } + kerr := rl.OnKeyEvent(event) + if kerr != nil { + if kerr == io.EOF { + aborted = true + lp.Quit(0) + return nil + } + if kerr == readline.ErrAcceptInput { + result = rl.AllText() + lp.Quit(0) + return nil + } + return kerr + } + if event.Handled { + rl.Redraw() + } + return nil + } + lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error { + terr := rl.OnText(text, from_key_event, in_bracketed_paste) + if terr == nil { + rl.Redraw() + } + return terr + } + err = lp.Run() + rl.Shutdown() + if err != nil { + return "", false, err + } + if ds := lp.DeathSignalName(); ds != "" { + return "", false, fmt.Errorf("killed by signal: %s", ds) + } + return result, aborted, nil +} + +func master_show_error(m *ControlMaster, msg string) { + if m.LastErrorLog != "" { + fmt.Fprintln(os.Stderr, m.LastErrorLog) + m.LastErrorLog = "" + } + show_error(msg) +} + +// save_as mirrors main.py:save_as. hostname is the CLI --hostname value used +// by check_hostname_matches. +func save_as(conn *SSHConnectionData, remote_path, hostname string) error { + ddir := utils.CacheDir() + if err := os.MkdirAll(ddir, 0o755); err != nil { + return err + } + last_used_store := filepath.Join(ddir, "remote-file-last-used.txt") + last_used_path := os.TempDir() + if b, err := os.ReadFile(last_used_store); err == nil { + last_used_path = string(b) + } + last_used_file := filepath.Join(last_used_path, filepath.Base(remote_path)) + ctx := markup.New(true) + fmt.Println("Where do you want to save the file? Leaving it blank will save it as:", ctx.Yellow(last_used_file)) + cwd, _ := os.Getwd() + fmt.Println("Relative paths will be resolved from:", ctx.Bold(cwd)) + fmt.Println() + + dest, aborted, err := get_save_path("> ") + if err != nil { + return err + } + if aborted { + return nil + } + if dest != "" { + dest = utils.Expanduser(os.ExpandEnv(dest)) + if st, err := os.Stat(dest); err == nil && st.IsDir() { + dest = filepath.Join(dest, filepath.Base(remote_path)) + } + if abs, err := filepath.Abs(dest); err == nil { + _ = os.WriteFile(last_used_store, []byte(filepath.Dir(abs)), 0o644) + dest = abs + } + } else { + dest = last_used_file + } + if _, err := os.Stat(dest); err == nil { + reset_terminal() + draw := func(lp *loop.Loop, mctx *markup.Context) { + lp.Println("The file " + mctx.Yellow(dest) + " already exists. What would you like to do?") + lp.Println(mctx.Green("O") + "verwrite " + mctx.Green("A") + "bort Auto " + mctx.Green("R") + "ename " + mctx.Green("N") + "ew name") + } + response, err := get_key_press(draw, "anor", "a") + if err != nil { + return err + } + switch response { + case "a": + return nil + case "n": + reset_terminal() + return save_as(conn, remote_path, hostname) + case "r": + dest = auto_rename_dest(dest, func(p string) bool { _, err := os.Stat(p); return err == nil }) + } + } + if d := filepath.Dir(dest); d != "" { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + master := new_control_master(conn, remote_path, dest) + if err := master.Start(); err != nil { + return err + } + defer master.Close() + ok, err := check_hostname_matches(master, hostname) + if err != nil { + return err + } + if !ok { + return nil + } + if err := master.Download(); err != nil { + master_show_error(master, "Failed to copy file from remote machine") + } + return nil +} + +// handle_action returns the path to open locally (for the "open" action) or "". +func handle_action(action string, opts *Options) (result string, err error) { + conn, err := parse_conn_data(opts.SshConnectionData) + if err != nil { + return "", err + } + remote_path := opts.Path + switch action { + case "open": + fmt.Println("Opening", opts.Path, "from", opts.Hostname) + tdir, err := os.MkdirTemp("", "kitty-remote-file") + if err != nil { + return "", err + } + dest := filepath.Join(tdir, filepath.Base(remote_path)) + master := new_control_master(conn, remote_path, dest) + if err := master.Start(); err != nil { + return "", err + } + defer master.Close() + ok, err := check_hostname_matches(master, opts.Hostname) + if err != nil { + return "", err + } + if !ok { + return "", nil + } + if err := master.Download(); err != nil { + master_show_error(master, "Failed to copy file from remote machine") + return "", nil + } + return dest, nil + case "edit": + fmt.Println("Editing", opts.Path, "from", opts.Hostname) + master := new_control_master(conn, remote_path, "") + if err := master.Start(); err != nil { + return "", err + } + defer master.Close() + ok, err := check_hostname_matches(master, opts.Hostname) + if err != nil { + return "", err + } + if !ok { + return "", nil + } + if err := master.Download(); err != nil { + master_show_error(master, "Failed to download "+remote_path) + return "", nil + } + if err := edit_loop(master, get_editor()); err != nil { + master_show_error(master, err.Error()) + } + case "save": + fmt.Println("Saving", opts.Path, "from", opts.Hostname) + if err := save_as(conn, remote_path, opts.Hostname); err != nil { + return "", err + } + } + return "", nil +} + +func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { + action := opts.Mode + if action == "ask" { + action, err = ask_action(opts) + reset_terminal() + if err != nil { + return 1, err + } + } + if action == "" || action == "cancel" { + return 0, nil + } + result, err := handle_action(action, opts) + if err != nil { + show_error(err.Error()) + return 1, nil + } + if result != "" { + serialized, err := tui.KittenOutputSerializer()(result) + if err != nil { + return 1, err + } + os.Stdout.WriteString(serialized) + } + return 0, nil +} + +func EntryPoint(parent *cli.Command) { + create_cmd(parent, main) +} diff --git a/kittens/remote_file/main.py b/kittens/remote_file/main.py index 00ea465af..feec4c819 100644 --- a/kittens/remote_file/main.py +++ b/kittens/remote_file/main.py @@ -2,33 +2,18 @@ # License: GPLv3 Copyright: 2020, Kovid Goyal -import json -import os -import shlex -import shutil -import subprocess import sys -import tempfile -import time -from typing import Any, Optional -from kitty.cli import parse_args -from kitty.cli_stub import RemoteFileCLIOptions -from kitty.constants import cache_dir from kitty.typing_compat import BossType -from kitty.utils import SSHConnectionData, command_for_open, get_editor, open_cmd +from kitty.utils import command_for_open, open_cmd from ..tui.handler import result_handler -from ..tui.operations import faint, raw_mode, reset_terminal, styled -from ..tui.utils import get_key_press +# Must match kittens/remote_file/ssh.go is_ssh_kitten_sentinel. Kept importable +# here because kitty/window.py:handle_remote_file imports it directly. is_ssh_kitten_sentinel = '!#*&$#($ssh-kitten)(##$' -def key(x: str) -> str: - return styled(x, bold=True, fg='green') - - def option_text() -> str: return '''\ --mode -m @@ -41,7 +26,7 @@ Which mode to operate in. Path to the remote file. ---hostname -h +--hostname Hostname of the remote host. @@ -50,312 +35,12 @@ The data used to connect over ssh. ''' -def show_error(msg: str) -> None: - print(styled(msg, fg='red'), file=sys.stderr) - print() - print('Press any key to quit', flush=True) - with raw_mode(): - while True: - try: - q = sys.stdin.buffer.read(1) - if q: - break - except (KeyboardInterrupt, EOFError): - break - - -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 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('{}ave the file'.format(key('S'))) - print(help_text('The file will be downloaded to a destination you select')) - print() - - print('{}ancel'.format(key('C'))) - print() - - sys.stdout.flush() - response = get_key_press('ceos', 'c') - return {'e': 'edit', 'o': 'open', 's': 'save'}.get(response, 'cancel') - - -def hostname_matches(from_hyperlink: str, actual: str) -> bool: - if from_hyperlink == actual: - return True - if from_hyperlink.partition('.')[0] == actual.partition('.')[0]: - return True - return False - - -class ControlMaster: - - def __init__(self, conn_data: SSHConnectionData, remote_path: str, cli_opts: RemoteFileCLIOptions, dest: str = ''): - self.conn_data = conn_data - self.cli_opts = cli_opts - self.remote_path = remote_path - self.dest = dest - self.tdir = '' - self.last_error_log = '' - self.cmd_prefix = cmd = [ - conn_data.binary, '-o', f'ControlPath=~/.ssh/kitty-rf-{os.getpid()}-%C', - '-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: - cmd.extend(['-p', str(conn_data.port)]) - if conn_data.identity_file: - cmd.extend(['-i', conn_data.identity_file]) - self.batch_cmd_prefix = cmd + ['-o', 'BatchMode=yes'] - - def check_call(self, cmd: list[str]) -> None: - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL) - stdout = p.communicate()[0] - if p.wait() != 0: - out = stdout.decode('utf-8', 'replace') - raise Exception(f'The ssh command: {shlex.join(cmd)} failed with exit code {p.returncode} and output: {out}') - - def __enter__(self) -> 'ControlMaster': - if not self.is_ssh_kitten: - self.check_call( - self.cmd_prefix + ['-o', 'ControlMaster=auto', '-fN', self.conn_data.hostname]) - self.check_call( - self.batch_cmd_prefix + ['-O', 'check', self.conn_data.hostname]) - if not self.dest: - self.tdir = tempfile.mkdtemp() - self.dest = os.path.join(self.tdir, os.path.basename(self.remote_path)) - return self - - def __exit__(self, *a: Any) -> None: - if not self.is_ssh_kitten: - subprocess.Popen( - self.batch_cmd_prefix + ['-O', 'exit', self.conn_data.hostname], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL - ).wait() - if self.tdir: - shutil.rmtree(self.tdir) - - @property - def is_alive(self) -> bool: - if self.is_ssh_kitten: - return True - return subprocess.Popen( - self.batch_cmd_prefix + ['-O', 'check', self.conn_data.hostname], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL - ).wait() == 0 - - 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, - stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL) - if cp.returncode == 0: - q = tuple(filter(None, cp.stdout.decode('utf-8').strip().splitlines()))[-1] - if not hostname_matches(self.cli_opts.hostname or '', q): - print(reset_terminal(), end='') - print(f'The remote hostname {styled(q, fg="green")} does not match the') - print(f'hostname in the hyperlink {styled(self.cli_opts.hostname or "", fg="red")}') - print('This indicates that kitty has not connected to the correct remote machine.') - print('This can happen, for example, when using nested SSH sessions.') - print(f'The hostname kitty used to connect was: {styled(self.conn_data.hostname, fg="yellow")}', end='') - if self.conn_data.port is not None: - print(f' with port: {self.conn_data.port}') - print() - print() - print('Do you want to continue anyway?') - print( - f'{styled("Y", fg="green")}es', - f'{styled("N", fg="red")}o', sep='\t' - ) - sys.stdout.flush() - response = get_key_press('yn', 'n') - print(reset_terminal(), end='') - return response == 'y' - 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: - cmdline = self.batch_cmd_prefix + [self.conn_data.hostname, 'cat', shlex.quote(self.remote_path)] - with open(self.dest, 'wb') as f: - cp = subprocess.run(cmdline, stdout=f, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL) - if cp.returncode != 0: - self.last_error_log = f'The command: {shlex.join(cmdline)} failed\n' + cp.stderr.decode() - return False - return True - - def upload(self, suppress_output: bool = True) -> bool: - cmd_prefix = self.cmd_prefix if suppress_output else self.batch_cmd_prefix - cmd = cmd_prefix + [self.conn_data.hostname, 'cat', '>', shlex.quote(self.remote_path)] - if not suppress_output: - print(shlex.join(cmd)) - with open(self.dest, 'rb') as f: - 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] - - -def main(args: list[str]) -> Result: - msg = 'Ask the user what to do with the remote file. For internal use by kitty, do not run it directly.' - try: - cli_opts, items = parse_args(args[1:], option_text, '', msg, 'kitty +kitten 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) - - try: - action = ask_action(cli_opts) - finally: - print(reset_terminal(), end='', flush=True) - try: - return handle_action(action, cli_opts) - except Exception: - print(reset_terminal(), end='', flush=True) - import traceback - traceback.print_exc() - show_error('Failed with unhandled exception') - return None - - -def save_as(conn_data: SSHConnectionData, remote_path: str, cli_opts: RemoteFileCLIOptions) -> None: - ddir = cache_dir() - os.makedirs(ddir, exist_ok=True) - last_used_store_path = os.path.join(ddir, 'remote-file-last-used.txt') - try: - with open(last_used_store_path) as f: - last_used_path = f.read() - except FileNotFoundError: - last_used_path = tempfile.gettempdir() - last_used_file = os.path.join(last_used_path, os.path.basename(remote_path)) - print( - 'Where do you want to save the file? Leaving it blank will save it as:', - styled(last_used_file, fg='yellow') - ) - print('Relative paths will be resolved from:', styled(os.getcwd(), fg_intense=True, bold=True)) - print() - from ..tui.path_completer import get_path - try: - dest = get_path() - except (KeyboardInterrupt, EOFError): - return - if dest: - dest = os.path.expandvars(os.path.expanduser(dest)) - if os.path.isdir(dest): - dest = os.path.join(dest, os.path.basename(remote_path)) - with open(last_used_store_path, 'w') as f: - f.write(os.path.dirname(os.path.abspath(dest))) - else: - dest = last_used_file - if os.path.exists(dest): - print(reset_terminal(), end='') - print(f'The file {styled(dest, fg="yellow")} already exists. What would you like to do?') - print(f'{key("O")}verwrite {key("A")}bort Auto {key("R")}ename {key("N")}ew name') - response = get_key_press('anor', 'a') - if response == 'a': - return - if response == 'n': - print(reset_terminal(), end='') - return save_as(conn_data, remote_path, cli_opts) - - if response == 'r': - q = dest - c = 0 - while os.path.exists(q): - c += 1 - b, ext = os.path.splitext(dest) - q = f'{b}-{c}{ext}' - dest = q - if os.path.dirname(dest): - os.makedirs(os.path.dirname(dest), exist_ok=True) - with ControlMaster(conn_data, remote_path, cli_opts, dest=dest) as master: - if master.check_hostname_matches(): - if not master.download(): - master.show_error('Failed to copy file from remote machine') - - -def handle_action(action: str, cli_opts: RemoteFileCLIOptions) -> Result: - 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 '' - if action == 'open': - print('Opening', cli_opts.path, 'from', cli_opts.hostname) - dest = os.path.join(tempfile.mkdtemp(), os.path.basename(remote_path)) - with ControlMaster(conn_data, remote_path, cli_opts, dest=dest) as master: - if master.check_hostname_matches(): - if master.download(): - return dest - master.show_error('Failed to copy file from remote machine') - elif action == 'edit': - print('Editing', cli_opts.path, 'from', cli_opts.hostname) - editor = get_editor() - with ControlMaster(conn_data, remote_path, cli_opts) as master: - if not master.check_hostname_matches(): - return None - if not master.download(): - master.show_error(f'Failed to download {remote_path}') - return None - mtime = os.path.getmtime(master.dest) - print(reset_terminal(), end='', flush=True) - editor_process = subprocess.Popen(editor + [master.dest]) - while editor_process.poll() is None: - time.sleep(0.1) - newmtime = os.path.getmtime(master.dest) - if newmtime > mtime: - mtime = newmtime - if master.is_alive: - master.upload() - print(reset_terminal(), end='', flush=True) - if master.is_alive: - if not master.upload(suppress_output=False): - master.show_error(f'Failed to upload {remote_path}') - else: - master.show_error(f'Failed to upload {remote_path}, SSH master process died') - elif action == 'save': - print('Saving', cli_opts.path, 'from', cli_opts.hostname) - save_as(conn_data, remote_path, cli_opts) - return None +def main(args: list[str]) -> None: + raise SystemExit('This should be run as kitten remote_file') @result_handler() -def handle_result(args: list[str], data: Result, target_window_id: int, boss: BossType) -> None: +def handle_result(args: list[str], data: str | None, target_window_id: int, boss: BossType) -> None: if data: from kitty.fast_data_types import get_options cmd = command_for_open(get_options().open_url_with) @@ -364,3 +49,9 @@ def handle_result(args: list[str], data: Result, target_window_id: int, boss: Bo if __name__ == '__main__': main(sys.argv) +elif __name__ == '__doc__': + cd = sys.cli_docs # type: ignore + cd['usage'] = '' + cd['options'] = option_text + cd['help_text'] = 'Ask the user what to do with the remote file. For internal use by kitty, do not run it directly.' + cd['short_desc'] = 'Handle remote files' diff --git a/shell-integration/ssh/kitty b/shell-integration/ssh/kitty index bc7471599..e4a4fc895 100755 --- a/shell-integration/ssh/kitty +++ b/shell-integration/ssh/kitty @@ -24,7 +24,7 @@ exec_kitty() { is_wrapped_kitten() { - wrapped_kittens="clipboard icat hyperlinked_grep ask hints unicode_input ssh themes diff show_key transfer query_terminal choose-files command-palette resize_window broadcast" + wrapped_kittens="clipboard icat hyperlinked_grep ask hints unicode_input ssh themes diff show_key transfer query_terminal choose-files command-palette resize_window broadcast remote_file" [ -n "$1" ] && { case " $wrapped_kittens " in *" $1 "*) printf "%s" "$1" ;; diff --git a/tools/cmd/tool/main.go b/tools/cmd/tool/main.go index d36f3a419..cdfd1265e 100644 --- a/tools/cmd/tool/main.go +++ b/tools/cmd/tool/main.go @@ -21,6 +21,7 @@ import ( "github.com/kovidgoyal/kitty/kittens/panel" "github.com/kovidgoyal/kitty/kittens/query_terminal" "github.com/kovidgoyal/kitty/kittens/quick_access_terminal" + "github.com/kovidgoyal/kitty/kittens/remote_file" "github.com/kovidgoyal/kitty/kittens/resize_window" "github.com/kovidgoyal/kitty/kittens/show_key" "github.com/kovidgoyal/kitty/kittens/ssh" @@ -72,6 +73,8 @@ func KittyToolEntryPoints(root *cli.Command) { resize_window.EntryPoint(root) // broadcast broadcast.EntryPoint(root) + // remote_file + remote_file.EntryPoint(root) // unicode_input unicode_input.EntryPoint(root) // show_key