package tui import ( "context" "fmt" "strings" "sync" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" cryptossh "golang.org/x/crypto/ssh" "git.tukangketik.id/swanadiva/hostkeeper/internal/models" sshclient "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh" ) // SessionTab represents an interactive SSH session as a tab type SessionTab struct { host *models.Host dataDir string client *sshclient.Client sess *cryptossh.Session width int height int mu sync.Mutex buffer strings.Builder connected bool done bool err error stdinPipe chan []byte outputCh chan sessionOutputMsg doneCh chan sessionDoneMsg windowCh chan struct{} closeOnce sync.Once closed chan struct{} cancel context.CancelFunc } // NewSessionTab creates a new SSH session tab func NewSessionTab(host *models.Host, dataDir string) *SessionTab { return &SessionTab{ host: host, dataDir: dataDir, width: 80, height: 24, stdinPipe: make(chan []byte, 256), outputCh: make(chan sessionOutputMsg, 64), doneCh: make(chan sessionDoneMsg, 1), windowCh: make(chan struct{}, 8), closed: make(chan struct{}), } } // Name returns the tab name func (t *SessionTab) Name() string { return t.host.Name } // Init starts the SSH connection in the background func (t *SessionTab) Init() tea.Cmd { ctx, cancel := context.WithCancel(context.Background()) t.cancel = cancel go t.connectAndStream(ctx) return t.pollSession } // pollSession polls for session output and done signals func (t *SessionTab) pollSession() tea.Msg { select { case msg := <-t.outputCh: t.mu.Lock() t.buffer.WriteString(string(msg)) t.mu.Unlock() return nil case done := <-t.doneCh: return done default: return nil } } // connectAndStream connects to the host and streams output func (t *SessionTab) connectAndStream(ctx context.Context) { // Create SSH client timeout := 30 * time.Second client := sshclient.NewClient(t.host, timeout) if err := client.Connect(ctx); err != nil { t.doneCh <- sessionDoneMsg{err: fmt.Errorf("connection failed: %w", err)} return } t.mu.Lock() t.client = client t.connected = true t.mu.Unlock() // Create session sshClient := client.GetClient() sess, err := sshClient.NewSession() if err != nil { client.Close() t.doneCh <- sessionDoneMsg{err: fmt.Errorf("session creation failed: %w", err)} return } t.mu.Lock() t.sess = sess t.mu.Unlock() width := t.width height := t.height // Request PTY modes := cryptossh.TerminalModes{ cryptossh.ECHO: 1, cryptossh.TTY_OP_ISPEED: 14400, cryptossh.TTY_OP_OSPEED: 14400, } if err := sess.RequestPty("xterm-256color", height, width, modes); err != nil { client.Close() t.doneCh <- sessionDoneMsg{err: fmt.Errorf("PTY request failed: %w", err)} return } // Set up pipes stdin, err := sess.StdinPipe() if err != nil { client.Close() t.doneCh <- sessionDoneMsg{err: fmt.Errorf("stdin pipe failed: %w", err)} return } stdout, err := sess.StdoutPipe() if err != nil { client.Close() t.doneCh <- sessionDoneMsg{err: fmt.Errorf("stdout pipe failed: %w", err)} return } stderr, err := sess.StderrPipe() if err != nil { client.Close() t.doneCh <- sessionDoneMsg{err: fmt.Errorf("stderr pipe failed: %w", err)} return } // Start shell if err := sess.Shell(); err != nil { client.Close() t.doneCh <- sessionDoneMsg{err: fmt.Errorf("shell start failed: %w", err)} return } // Read stdout in a goroutine stdoutDone := make(chan struct{}) go func() { defer close(stdoutDone) buf := make([]byte, 4096) for { n, err := stdout.Read(buf) if n > 0 { select { case t.outputCh <- sessionOutputMsg(string(buf[:n])): default: } } if err != nil { return } } }() // Read stderr in a goroutine stderrDone := make(chan struct{}) go func() { defer close(stderrDone) buf := make([]byte, 4096) for { n, err := stderr.Read(buf) if n > 0 { select { case t.outputCh <- sessionOutputMsg(string(buf[:n])): default: } } if err != nil { return } } }() // Write stdin (from channel to SSH pipe) stdinDone := make(chan struct{}) go func() { defer close(stdinDone) for { select { case data := <-t.stdinPipe: stdin.Write(data) case <-t.windowCh: t.mu.Lock() sess.WindowChange(t.height, t.width) t.mu.Unlock() case <-t.closed: return case <-ctx.Done(): return } } }() // Wait for session to finish sess.Wait() // Cleanup close(stdinDone) <-stdoutDone <-stderrDone client.Close() t.doneCh <- sessionDoneMsg{err: nil} } // Update handles messages for the session tab func (t *SessionTab) Update(msg tea.Msg) (Tab, tea.Cmd) { if t.done { return t, nil } switch msg := msg.(type) { case tea.WindowSizeMsg: t.mu.Lock() t.width = msg.Width t.height = msg.Height - 1 // account for status bar t.mu.Unlock() select { case t.windowCh <- struct{}{}: default: } case tea.KeyMsg: // Send all key input to SSH stdin data := keyMsgToBytes(msg) if len(data) > 0 { select { case t.stdinPipe <- data: default: } } case sessionDoneMsg: t.mu.Lock() t.done = true if msg.err != nil { t.err = msg.err } t.mu.Unlock() } return t, t.pollSession } // View renders the session tab content func (t *SessionTab) View() string { t.mu.Lock() defer t.mu.Unlock() if !t.connected { return HighlightStyle.Render(fmt.Sprintf("Connecting to %s...", t.host.Name)) } if t.done && t.err != nil { return fmt.Sprintf("%s\n\n%s", ErrorStyle.Render(fmt.Sprintf("Session ended: %v", t.err)), SubtitleStyle.Render("Press Ctrl+Q to close this tab")) } if t.done { return fmt.Sprintf("%s\n\n%s", SubtitleStyle.Render("Session ended"), SubtitleStyle.Render("Press Ctrl+Q to close this tab")) } content := t.buffer.String() if content == "" { return "Connected. Waiting for output..." } // Only show last N lines to avoid unbounded memory lines := strings.Split(content, "\n") const maxLines = 500 if len(lines) > maxLines { lines = lines[len(lines)-maxLines:] } visible := strings.Join(lines, "\n") return lipgloss.NewStyle().MaxHeight(t.height - 2).Render(visible) } // Close terminates the session func (t *SessionTab) Close() { t.closeOnce.Do(func() { close(t.closed) if t.cancel != nil { t.cancel() } t.mu.Lock() if t.sess != nil { t.sess.Close() } if t.client != nil { t.client.Close() } t.mu.Unlock() }) } // keyMsgToBytes converts a Bubble Tea key message to SSH terminal bytes func keyMsgToBytes(msg tea.KeyMsg) []byte { switch msg.Type { case tea.KeyEnter: return []byte("\r") case tea.KeyBackspace: return []byte("\x7f") case tea.KeyTab: return []byte("\t") case tea.KeySpace: return []byte(" ") case tea.KeyUp: return []byte("\x1b[A") case tea.KeyDown: return []byte("\x1b[B") case tea.KeyRight: return []byte("\x1b[C") case tea.KeyLeft: return []byte("\x1b[D") case tea.KeyEscape: return []byte("\x1b") case tea.KeyDelete: return []byte("\x1b[3~") case tea.KeyHome: return []byte("\x1b[H") case tea.KeyEnd: return []byte("\x1b[F") case tea.KeyPgUp: return []byte("\x1b[5~") case tea.KeyPgDown: return []byte("\x1b[6~") default: if len(msg.Runes) > 0 { return []byte(string(msg.Runes)) } return []byte(msg.String()) } }