Files
OBrutus a0978a74a2 remote_file kitten: port ask UI to Go, wrap and shrink Python to result-handler shim
Adds kittens/remote_file/main.go (ask menu, hostname-mismatch prompt,
save-path prompt with tab completion, edit/open/save actions) on top of the
ControlMaster/ssh helpers from the previous two commits, registers it in
tools/cmd/tool/main.go, and adds remote_file to shell-integration/ssh/kitty's
wrapped_kittens list so both `kitty +kitten remote_file` and ssh-session
invocations exec the Go binary directly. main.py is shrunk to option_text
(kept importable by kitty/cli_stub.py and used for --doc/--help generation),
is_ssh_kitten_sentinel (kept importable by kitty/window.py), and the
handle_result shim boss.py runs in-process to open the returned file.

Also drops the dead `-h` alias on --hostname: Python's CLI layer always
special-cases -h/--help ahead of user options, so it never worked as a
hostname shorthand, but the Go CLI layer hard-errors on the collision; removing
it is a no-op for existing behavior (boss.py only ever passes --hostname) and
unblocks `kitten remote-file --help`. Also adds the same reset_terminal()
calls Python makes between UI phases (ask menu -> action, hostname-mismatch
prompt, overwrite prompt, $EDITOR handoff) so the Go and Python screens match.

Manually verified parity against Python (ask menu, cancel, open/edit/save
download+upload round-trips, hostname-mismatch Y/N, save-path prompt and
overwrite O/A/R/N) using a fake-ssh test double, and confirmed post-wrap that
`kitty +kitten remote_file` execs the Go kitten binary.
2026-08-13 15:21:57 +05:30

203 lines
5.6 KiB
Go

// License: GPLv3 Copyright: 2026, Kovid Goyal, <kovid at kovidgoyal.net>
package remote_file
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/kovidgoyal/kitty/tools/utils/shlex"
)
type ControlMaster struct {
conn *SSHConnectionData
remote_path string
tdir string
Dest string
LastErrorLog string
}
func new_control_master(conn *SSHConnectionData, remote_path, dest string) *ControlMaster {
return &ControlMaster{conn: conn, remote_path: remote_path, Dest: dest}
}
func (m *ControlMaster) check_call(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = nil
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("the ssh command: %s failed: %w with output: %s", strings.Join(argv, " "), err, string(out))
}
return nil
}
func (m *ControlMaster) Start() (err error) {
if !m.conn.IsSSHKitten {
if err = m.check_call(append(m.conn.cmd_prefix(), "-o", "ControlMaster=auto", "-fN", m.conn.Hostname)); err != nil {
return err
}
if err = m.check_call(append(m.conn.batch_cmd_prefix(), "-O", "check", m.conn.Hostname)); err != nil {
return err
}
}
if m.Dest == "" {
if m.tdir, err = os.MkdirTemp("", "kitty-remote-file"); err != nil {
return err
}
m.Dest = filepath.Join(m.tdir, filepath.Base(m.remote_path))
}
return nil
}
func (m *ControlMaster) Close() {
if !m.conn.IsSSHKitten {
argv := append(m.conn.batch_cmd_prefix(), "-O", "exit", m.conn.Hostname)
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
_ = cmd.Run()
}
if m.tdir != "" {
_ = os.RemoveAll(m.tdir)
}
}
func (m *ControlMaster) IsAlive() bool {
if m.conn.IsSSHKitten {
return true
}
argv := append(m.conn.batch_cmd_prefix(), "-O", "check", m.conn.Hostname)
return exec.Command(argv[0], argv[1:]...).Run() == nil
}
func (m *ControlMaster) Download() error {
argv := append(m.conn.batch_cmd_prefix(), m.conn.Hostname, "cat", shlex.Quote(m.remote_path))
f, err := os.Create(m.Dest)
if err != nil {
return err
}
defer f.Close()
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdout = f
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
m.LastErrorLog = fmt.Sprintf("The command: %s failed\n%s", strings.Join(argv, " "), stderr.String())
return err
}
return nil
}
func (m *ControlMaster) Upload(suppress_output bool) error {
prefix := m.conn.cmd_prefix()
if !suppress_output {
prefix = m.conn.batch_cmd_prefix()
}
argv := append(prefix, m.conn.Hostname, "cat", ">", shlex.Quote(m.remote_path))
if !suppress_output {
fmt.Println(strings.Join(argv, " "))
}
f, err := os.Open(m.Dest)
if err != nil {
return err
}
defer f.Close()
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = f
if suppress_output {
out, err := cmd.CombinedOutput()
if err != nil {
m.LastErrorLog = fmt.Sprintf("The command: %s failed\n%s", strings.Join(argv, " "), string(out))
return err
}
return nil
}
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd.Run()
}
// remote_hostname returns the remote machine's `hostname -f` output, or "" on failure.
// Mirrors Python's check_hostname_matches: strip the combined output, split on
// lines, drop empty lines, and take the last non-empty line.
func (m *ControlMaster) remote_hostname() string {
if m.conn.IsSSHKitten {
return ""
}
argv := append(m.conn.batch_cmd_prefix(), m.conn.Hostname, "hostname", "-f")
out, err := exec.Command(argv[0], argv[1:]...).Output()
if err != nil {
return ""
}
last := ""
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line != "" {
last = line
}
}
return last
}
// get_editor mirrors kitty/utils.py:get_editor_from_env_vars: check $VISUAL then
// $EDITOR (shlex-split), then fall back to the first available editor from the
// exact candidate list used there, defaulting to "vim" if none are found on PATH.
func get_editor() []string {
for _, env := range []string{"VISUAL", "EDITOR"} {
if v := os.Getenv(env); v != "" {
if parts, err := shlex.Split(v); err == nil && len(parts) > 0 {
return parts
}
}
}
// Exact candidate list from kitty/utils.py get_editor_from_env_vars.
for _, e := range []string{"vim", "nvim", "vi", "emacs", "hx", "kak", "micro", "nano", "vis"} {
if p, err := exec.LookPath(e); err == nil {
return []string{p}
}
}
return []string{"vim"}
}
func edit_loop(master *ControlMaster, editor []string) error {
st, err := os.Stat(master.Dest)
if err != nil {
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
if err := cmd.Start(); err != nil {
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
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)
}
return nil
}
return fmt.Errorf("failed to upload %s, SSH master process died", master.remote_path)
case <-time.After(100 * time.Millisecond):
if st, err := os.Stat(master.Dest); err == nil && st.ModTime().After(mtime) {
mtime = st.ModTime()
if master.IsAlive() {
_ = master.Upload(true)
}
}
}
}
}