diff --git a/cmd/hostkeeper/tui.go b/cmd/hostkeeper/tui.go index f83481b..4ddc2d2 100644 --- a/cmd/hostkeeper/tui.go +++ b/cmd/hostkeeper/tui.go @@ -46,6 +46,7 @@ func runTUI(cmd *cobra.Command, args []string) error { } model := tui.New() + model.SetDataDir(cfg.GetDataDir()) model.LoadHosts(hosts) p := tea.NewProgram(model) diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go index 16f65d4..afedd44 100644 --- a/pkg/tui/host_list_tab.go +++ b/pkg/tui/host_list_tab.go @@ -56,7 +56,15 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) { case "enter", " ": if len(t.hosts) > 0 { - return t, tea.Quit + host := t.hosts[t.selectedIndex] + return t, func() tea.Msg { + return openSessionMsg{host: host} + } + } + + case "q", "ctrl+c": + return t, func() tea.Msg { + return quitMsg{} } } } @@ -89,6 +97,9 @@ func (t *HostListTab) View() string { return b.String() } +// Close is a no-op for host list tab +func (t *HostListTab) Close() {} + // SetHosts sets the host list func (t *HostListTab) SetHosts(hosts []*models.Host) { t.hosts = hosts diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go new file mode 100644 index 0000000..0d892d0 --- /dev/null +++ b/pkg/tui/messages.go @@ -0,0 +1,20 @@ +package tui + +import "git.tukangketik.id/swanadiva/hostkeeper/internal/models" + +// quitMsg signals the TUI to exit +type quitMsg struct{} + +// openSessionMsg signals the TUI to open a new SSH session tab +type openSessionMsg struct { + host *models.Host + dataDir string +} + +// sessionOutputMsg carries SSH output to the TUI renderer +type sessionOutputMsg string + +// sessionDoneMsg signals that a session has ended +type sessionDoneMsg struct { + err error +} diff --git a/pkg/tui/session.go b/pkg/tui/session.go new file mode 100644 index 0000000..e364cdd --- /dev/null +++ b/pkg/tui/session.go @@ -0,0 +1,368 @@ +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()) + } +} + + diff --git a/pkg/tui/tabs.go b/pkg/tui/tabs.go index 3479e24..bec1a1e 100644 --- a/pkg/tui/tabs.go +++ b/pkg/tui/tabs.go @@ -12,6 +12,8 @@ type Tab interface { Update(tea.Msg) (Tab, tea.Cmd) View() string Name() string + // Close is called when the tab is removed; implement for cleanup (e.g. disconnect SSH) + Close() } // TabManager manages multiple tabs @@ -38,10 +40,11 @@ func (tm *TabManager) Active() Tab { return tm.tabs[tm.active] } -// Add adds a new tab and switches to it -func (tm *TabManager) Add(tab Tab) { +// Add adds a new tab, switches to it, and returns its init command +func (tm *TabManager) Add(tab Tab) tea.Cmd { tm.tabs = append(tm.tabs, tab) tm.active = len(tm.tabs) - 1 + return tab.Init() } // Close removes the tab at index and returns the active tab @@ -50,6 +53,9 @@ func (tm *TabManager) Close(index int) Tab { return nil } + // Call Close for cleanup (e.g. disconnect SSH) + tm.tabs[index].Close() + tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...) if len(tm.tabs) == 0 { diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index acd4e27..a73644e 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -23,6 +23,7 @@ type Model struct { SelectedIndex int // deprecated Error error Quit bool + dataDir string } // New creates a new TUI model @@ -47,11 +48,26 @@ func (m *Model) Init() tea.Cmd { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": + // Only hard quit on ctrl+c (let tabs handle q) + if msg.String() == "ctrl+c" { m.Quit = true return m, tea.Quit } + + case quitMsg: + m.Quit = true + return m, tea.Quit + + case openSessionMsg: + tab := NewSessionTab(msg.host, m.dataDir) + cmd := m.tabs.Add(tab) + return m, cmd + + case sessionDoneMsg: + if msg.err != nil { + m.Error = msg.err + } + return m, nil } cmd, err := m.tabs.Update(msg) @@ -94,6 +110,11 @@ func (m *Model) LoadHosts(hosts []*models.Host) { } } +// SetDataDir sets the data directory for SSH connections +func (m *Model) SetDataDir(dir string) { + m.dataDir = dir +} + // TabManager returns the underlying tab manager func (m *Model) TabManager() *TabManager { return m.tabs