mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-10-11 02:49:41 +02:00
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.
203 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|