diff --git a/README.md b/README.md index 6757196..25e23ab 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect ### 🎯 **Core Features** - **🎨 Beautiful TUI Interface** - Navigate your SSH hosts with an elegant, interactive terminal UI - **⚡ Quick Connect** - Connect to any host instantly -- **📝 Easy Management** - Add, edit, and manage SSH configurations seamlessly +- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding +- **📝Easy Management** - Add, edit, and manage SSH configurations seamlessly - **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization - **🔍 Smart Search** - Find hosts quickly with built-in filtering and search - **🔒 Secure** - Works directly with your existing `~/.ssh/config` file @@ -40,6 +41,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect - **Add new SSH hosts** with interactive forms - **Edit existing configurations** in-place - **Delete hosts** with confirmation prompts +- **Port forwarding setup** with intuitive interface for Local (-L), Remote (-R), and Dynamic (-D) forwarding - **Backup configurations** automatically before changes - **Validate settings** to prevent configuration errors - **ProxyJump support** for secure connection tunneling through bastion hosts @@ -102,6 +104,7 @@ sshm - `a` - Add new host - `e` - Edit selected host - `d` - Delete selected host +- `f` - Port forwarding setup - `q` - Quit - `/` - Search/filter hosts @@ -122,6 +125,90 @@ The interactive forms will guide you through configuration: - **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) - **Tags** - Comma-separated tags for organization +### Port Forwarding + +SSHM provides an intuitive interface for setting up SSH port forwarding. Press `f` while selecting a host to open the port forwarding setup: + +**Forward Types:** +- **Local (-L)** - Forward a local port to a remote host/port through the SSH connection + - Example: Access a remote database on `localhost:5432` via local port `15432` + - Use case: `ssh -L 15432:localhost:5432 server` → Database accessible on `localhost:15432` + +- **Remote (-R)** - Forward a remote port back to a local host/port + - Example: Expose local web server on remote host's port `8080` + - Use case: `ssh -R 8080:localhost:3000 server` → Local app accessible from remote host's port 8080 + - ⚠️ **Requirements for external access:** + - **SSH Server Config**: Add `GatewayPorts yes` to `/etc/ssh/sshd_config` and restart SSH service + - **Firewall**: Open the remote port in the server's firewall (`ufw allow 8080` or equivalent) + - **Port Availability**: Ensure the remote port is not already in use + - **Bind Address**: Use `0.0.0.0` for external access, `127.0.0.1` for local-only + +- **Dynamic (-D)** - Create a SOCKS proxy for secure browsing + - Example: Route web traffic through the SSH connection + - Use case: `ssh -D 1080 server` → Configure browser to use `localhost:1080` as SOCKS proxy + - ⚠️ **Configuration requirements:** + - **Browser Setup**: Configure SOCKS v5 proxy in browser settings + - **DNS**: Enable "Proxy DNS when using SOCKS v5" for full privacy + - **Applications**: Only SOCKS-aware applications will use the proxy + - **Bind Address**: Use `127.0.0.1` for security (local access only) + +**Port Forwarding Interface:** +- Choose forward type with ←/→ arrow keys +- Configure ports and addresses with guided forms +- Optional bind address configuration (defaults to 127.0.0.1) +- Real-time validation of port numbers and addresses +- Connect automatically with configured forwarding options + +**Troubleshooting Port Forwarding:** + +*Remote Forwarding Issues:* +```bash +# Error: "remote port forwarding failed for listen port X" +# Solutions: +1. Check if port is already in use: ssh server "netstat -tln | grep :X" +2. Use a different port that's available +3. Enable GatewayPorts in SSH config for external access +``` + +*SSH Server Configuration for Remote Forwarding:* +```bash +# Edit SSH daemon config on the server: +sudo nano /etc/ssh/sshd_config + +# Add or uncomment: +GatewayPorts yes + +# Restart SSH service: +sudo systemctl restart sshd # Ubuntu/Debian/CentOS 7+ +# OR +sudo service ssh restart # Older systems +``` + +*Firewall Configuration:* +```bash +# Ubuntu/Debian (UFW): +sudo ufw allow [port_number] + +# CentOS/RHEL/Rocky (firewalld): +sudo firewall-cmd --add-port=[port_number]/tcp --permanent +sudo firewall-cmd --reload + +# Check if port is accessible: +telnet [server_ip] [port_number] +``` + +*Dynamic Forwarding (SOCKS) Browser Setup:* +``` +Firefox: about:preferences → Network Settings +- Manual proxy configuration +- SOCKS Host: localhost, Port: [your_port] +- SOCKS v5: ✓ +- Proxy DNS when using SOCKS v5: ✓ + +Chrome: Launch with proxy +chrome --proxy-server="socks5://localhost:[your_port]" +``` + ### CLI Usage SSHM provides both command-line operations and an interactive TUI interface: diff --git a/internal/ui/model.go b/internal/ui/model.go index 28f5bbe..8a9efe4 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -35,8 +35,31 @@ const ( ViewList ViewMode = iota ViewAdd ViewEdit + ViewPortForward ) +// PortForwardType defines the type of port forwarding +type PortForwardType int + +const ( + LocalForward PortForwardType = iota + RemoteForward + DynamicForward +) + +func (p PortForwardType) String() string { + switch p { + case LocalForward: + return "Local (-L)" + case RemoteForward: + return "Remote (-R)" + case DynamicForward: + return "Dynamic (-D)" + default: + return "Local (-L)" + } +} + // Model represents the state of the user interface type Model struct { table table.Model @@ -51,9 +74,10 @@ type Model struct { configFile string // Path to the SSH config file // View management - viewMode ViewMode - addForm *addFormModel - editForm *editFormModel + viewMode ViewMode + addForm *addFormModel + editForm *editFormModel + portForwardForm *portForwardModel // Terminal size and styles width int diff --git a/internal/ui/port_forward_form.go b/internal/ui/port_forward_form.go new file mode 100644 index 0000000..49a89ea --- /dev/null +++ b/internal/ui/port_forward_form.go @@ -0,0 +1,490 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Input field indices for port forward form +const ( + pfTypeInput = iota + pfLocalPortInput + pfRemoteHostInput + pfRemotePortInput + pfBindAddressInput +) + +type portForwardModel struct { + inputs []textinput.Model + focused int + forwardType PortForwardType + hostName string + err string + styles Styles + width int + height int + configFile string +} + +// portForwardSubmitMsg is sent when the port forward form is submitted +type portForwardSubmitMsg struct { + err error + sshArgs []string +} + +// portForwardCancelMsg is sent when the port forward form is cancelled +type portForwardCancelMsg struct{} + +// NewPortForwardForm creates a new port forward form model +func NewPortForwardForm(hostName string, styles Styles, width, height int, configFile string) *portForwardModel { + inputs := make([]textinput.Model, 5) + + // Forward type input (display only, controlled by arrow keys) + inputs[pfTypeInput] = textinput.New() + inputs[pfTypeInput].Placeholder = "Use ←/→ to change forward type" + inputs[pfTypeInput].Focus() + inputs[pfTypeInput].Width = 40 + inputs[pfTypeInput].SetValue("Local (-L)") + + // Local port input + inputs[pfLocalPortInput] = textinput.New() + inputs[pfLocalPortInput].Placeholder = "8080" + inputs[pfLocalPortInput].CharLimit = 5 + inputs[pfLocalPortInput].Width = 20 + + // Remote host input + inputs[pfRemoteHostInput] = textinput.New() + inputs[pfRemoteHostInput].Placeholder = "localhost" + inputs[pfRemoteHostInput].CharLimit = 100 + inputs[pfRemoteHostInput].Width = 30 + inputs[pfRemoteHostInput].SetValue("localhost") + + // Remote port input + inputs[pfRemotePortInput] = textinput.New() + inputs[pfRemotePortInput].Placeholder = "80" + inputs[pfRemotePortInput].CharLimit = 5 + inputs[pfRemotePortInput].Width = 20 + + // Bind address input (optional) + inputs[pfBindAddressInput] = textinput.New() + inputs[pfBindAddressInput].Placeholder = "127.0.0.1 (optional)" + inputs[pfBindAddressInput].CharLimit = 50 + inputs[pfBindAddressInput].Width = 30 + + pf := &portForwardModel{ + inputs: inputs, + focused: 0, + forwardType: LocalForward, + hostName: hostName, + styles: styles, + width: width, + height: height, + configFile: configFile, + } + + // Initialize input visibility + pf.updateInputVisibility() + + return pf +} + +func (m *portForwardModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m *portForwardModel) Update(msg tea.Msg) (*portForwardModel, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "ctrl+c": + return m, func() tea.Msg { return portForwardCancelMsg{} } + + case "enter": + nextField := m.getNextValidField(m.focused) + if nextField != -1 { + // Move to next valid input + m.inputs[m.focused].Blur() + m.focused = nextField + m.inputs[m.focused].Focus() + return m, textinput.Blink + } else { + // Submit form + return m, m.submitForm() + } + + case "shift+tab", "up": + prevField := m.getPrevValidField(m.focused) + if prevField != -1 { + m.inputs[m.focused].Blur() + m.focused = prevField + m.inputs[m.focused].Focus() + return m, textinput.Blink + } + + case "tab", "down": + nextField := m.getNextValidField(m.focused) + if nextField != -1 { + m.inputs[m.focused].Blur() + m.focused = nextField + m.inputs[m.focused].Focus() + return m, textinput.Blink + } + + case "left", "right": + if m.focused == pfTypeInput { + // Change forward type + if msg.String() == "left" { + if m.forwardType > 0 { + m.forwardType-- + } else { + m.forwardType = DynamicForward + } + } else { + if m.forwardType < DynamicForward { + m.forwardType++ + } else { + m.forwardType = LocalForward + } + } + m.inputs[pfTypeInput].SetValue(m.forwardType.String()) + m.updateInputVisibility() + + // Ensure focused field is valid for the new type + validFields := m.getValidFields() + validFocus := false + for _, field := range validFields { + if field == m.focused { + validFocus = true + break + } + } + if !validFocus && len(validFields) > 0 { + m.inputs[m.focused].Blur() + m.focused = validFields[0] + m.inputs[m.focused].Focus() + } + + return m, nil + } + } + } + + // Update the focused input + m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg) + return m, cmd +} + +func (m *portForwardModel) updateInputVisibility() { + // Reset all inputs visibility + for i := range m.inputs { + if i != pfTypeInput { + m.inputs[i].Placeholder = "" + } + } + + switch m.forwardType { + case LocalForward: + m.inputs[pfLocalPortInput].Placeholder = "Local port (e.g., 8080)" + m.inputs[pfRemoteHostInput].Placeholder = "Remote host (e.g., localhost)" + m.inputs[pfRemotePortInput].Placeholder = "Remote port (e.g., 80)" + m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)" + case RemoteForward: + m.inputs[pfLocalPortInput].Placeholder = "Remote port (e.g., 8080)" + m.inputs[pfRemoteHostInput].Placeholder = "Local host (e.g., localhost)" + m.inputs[pfRemotePortInput].Placeholder = "Local port (e.g., 80)" + m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional)" + case DynamicForward: + m.inputs[pfLocalPortInput].Placeholder = "SOCKS port (e.g., 1080)" + m.inputs[pfRemoteHostInput].Placeholder = "" + m.inputs[pfRemotePortInput].Placeholder = "" + m.inputs[pfBindAddressInput].Placeholder = "Bind address (optional, default: 127.0.0.1)" + } +} + +func (m *portForwardModel) View() string { + var sections []string + + // Title + title := m.styles.Header.Render("🔗 Port Forwarding Setup") + sections = append(sections, title) + + // Host info + hostInfo := fmt.Sprintf("Host: %s", m.hostName) + sections = append(sections, m.styles.HelpText.Render(hostInfo)) + + // Error message + if m.err != "" { + sections = append(sections, m.styles.Error.Render("Error: "+m.err)) + } + + // Form fields + var fields []string + + // Forward type + typeLabel := "Forward Type:" + if m.focused == pfTypeInput { + typeLabel = m.styles.FocusedLabel.Render(typeLabel) + } else { + typeLabel = m.styles.Label.Render(typeLabel) + } + fields = append(fields, typeLabel) + fields = append(fields, m.inputs[pfTypeInput].View()) + fields = append(fields, m.styles.HelpText.Render("Use ←/→ to change type")) + + switch m.forwardType { + case LocalForward: + fields = append(fields, "") + fields = append(fields, m.styles.HelpText.Render("Local forwarding: ssh -L [bind_address:]local_port:remote_host:remote_port")) + fields = append(fields, "") + + // Local port + localPortLabel := "Local Port:" + if m.focused == pfLocalPortInput { + localPortLabel = m.styles.FocusedLabel.Render(localPortLabel) + } else { + localPortLabel = m.styles.Label.Render(localPortLabel) + } + fields = append(fields, localPortLabel) + fields = append(fields, m.inputs[pfLocalPortInput].View()) + + // Remote host + remoteHostLabel := "Remote Host:" + if m.focused == pfRemoteHostInput { + remoteHostLabel = m.styles.FocusedLabel.Render(remoteHostLabel) + } else { + remoteHostLabel = m.styles.Label.Render(remoteHostLabel) + } + fields = append(fields, remoteHostLabel) + fields = append(fields, m.inputs[pfRemoteHostInput].View()) + + // Remote port + remotePortLabel := "Remote Port:" + if m.focused == pfRemotePortInput { + remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel) + } else { + remotePortLabel = m.styles.Label.Render(remotePortLabel) + } + fields = append(fields, remotePortLabel) + fields = append(fields, m.inputs[pfRemotePortInput].View()) + + case RemoteForward: + fields = append(fields, "") + fields = append(fields, m.styles.HelpText.Render("Remote forwarding: ssh -R [bind_address:]remote_port:local_host:local_port")) + fields = append(fields, "") + + // Remote port + remotePortLabel := "Remote Port:" + if m.focused == pfLocalPortInput { + remotePortLabel = m.styles.FocusedLabel.Render(remotePortLabel) + } else { + remotePortLabel = m.styles.Label.Render(remotePortLabel) + } + fields = append(fields, remotePortLabel) + fields = append(fields, m.inputs[pfLocalPortInput].View()) + + // Local host + localHostLabel := "Local Host:" + if m.focused == pfRemoteHostInput { + localHostLabel = m.styles.FocusedLabel.Render(localHostLabel) + } else { + localHostLabel = m.styles.Label.Render(localHostLabel) + } + fields = append(fields, localHostLabel) + fields = append(fields, m.inputs[pfRemoteHostInput].View()) + + // Local port + localPortLabel := "Local Port:" + if m.focused == pfRemotePortInput { + localPortLabel = m.styles.FocusedLabel.Render(localPortLabel) + } else { + localPortLabel = m.styles.Label.Render(localPortLabel) + } + fields = append(fields, localPortLabel) + fields = append(fields, m.inputs[pfRemotePortInput].View()) + + case DynamicForward: + fields = append(fields, "") + fields = append(fields, m.styles.HelpText.Render("Dynamic forwarding (SOCKS proxy): ssh -D [bind_address:]port")) + fields = append(fields, "") + + // SOCKS port + socksPortLabel := "SOCKS Port:" + if m.focused == pfLocalPortInput { + socksPortLabel = m.styles.FocusedLabel.Render(socksPortLabel) + } else { + socksPortLabel = m.styles.Label.Render(socksPortLabel) + } + fields = append(fields, socksPortLabel) + fields = append(fields, m.inputs[pfLocalPortInput].View()) + } + + // Bind address (for all types) + fields = append(fields, "") + bindLabel := "Bind Address (optional):" + if m.focused == pfBindAddressInput { + bindLabel = m.styles.FocusedLabel.Render(bindLabel) + } else { + bindLabel = m.styles.Label.Render(bindLabel) + } + fields = append(fields, bindLabel) + fields = append(fields, m.inputs[pfBindAddressInput].View()) + + // Join form fields + formContent := lipgloss.JoinVertical(lipgloss.Left, fields...) + sections = append(sections, formContent) + + // Help text + helpText := " Tab/↓: next field • Shift+Tab/↑: previous field • Enter: connect • Esc: cancel" + sections = append(sections, m.styles.HelpText.Render(helpText)) + + // Join all sections + content := lipgloss.JoinVertical(lipgloss.Left, sections...) + + // Center the form + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + m.styles.FormContainer.Render(content), + ) +} + +func (m *portForwardModel) submitForm() tea.Cmd { + return func() tea.Msg { + // Validate inputs + localPort := strings.TrimSpace(m.inputs[pfLocalPortInput].Value()) + if localPort == "" { + return portForwardSubmitMsg{err: fmt.Errorf("port is required"), sshArgs: nil} + } + + // Validate port number + if _, err := strconv.Atoi(localPort); err != nil { + return portForwardSubmitMsg{err: fmt.Errorf("invalid port number"), sshArgs: nil} + } + + // Build SSH command with port forwarding + var sshArgs []string + + // Add config file if specified + if m.configFile != "" { + sshArgs = append(sshArgs, "-F", m.configFile) + } + + // Add forwarding arguments + bindAddress := strings.TrimSpace(m.inputs[pfBindAddressInput].Value()) + + switch m.forwardType { + case LocalForward: + remoteHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value()) + remotePort := strings.TrimSpace(m.inputs[pfRemotePortInput].Value()) + + if remoteHost == "" { + remoteHost = "localhost" + } + if remotePort == "" { + return portForwardSubmitMsg{err: fmt.Errorf("remote port is required for local forwarding"), sshArgs: nil} + } + + // Validate remote port + if _, err := strconv.Atoi(remotePort); err != nil { + return portForwardSubmitMsg{err: fmt.Errorf("invalid remote port number"), sshArgs: nil} + } + + // Build -L argument + var forwardArg string + if bindAddress != "" { + forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, remoteHost, remotePort) + } else { + forwardArg = fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort) + } + sshArgs = append(sshArgs, "-L", forwardArg) + + case RemoteForward: + localHost := strings.TrimSpace(m.inputs[pfRemoteHostInput].Value()) + localPortStr := strings.TrimSpace(m.inputs[pfRemotePortInput].Value()) + + if localHost == "" { + localHost = "localhost" + } + if localPortStr == "" { + return portForwardSubmitMsg{err: fmt.Errorf("local port is required for remote forwarding"), sshArgs: nil} + } + + // Validate local port + if _, err := strconv.Atoi(localPortStr); err != nil { + return portForwardSubmitMsg{err: fmt.Errorf("invalid local port number"), sshArgs: nil} + } + + // Build -R argument (note: localPort is actually the remote port in this context) + var forwardArg string + if bindAddress != "" { + forwardArg = fmt.Sprintf("%s:%s:%s:%s", bindAddress, localPort, localHost, localPortStr) + } else { + forwardArg = fmt.Sprintf("%s:%s:%s", localPort, localHost, localPortStr) + } + sshArgs = append(sshArgs, "-R", forwardArg) + + case DynamicForward: + // Build -D argument + var forwardArg string + if bindAddress != "" { + forwardArg = fmt.Sprintf("%s:%s", bindAddress, localPort) + } else { + forwardArg = localPort + } + sshArgs = append(sshArgs, "-D", forwardArg) + } + + // Add hostname + sshArgs = append(sshArgs, m.hostName) + + // Return success with the SSH command to execute + return portForwardSubmitMsg{err: nil, sshArgs: sshArgs} + } +} + +// getValidFields returns the list of valid field indices for the current forward type +func (m *portForwardModel) getValidFields() []int { + switch m.forwardType { + case LocalForward: + return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput} + case RemoteForward: + return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput} + case DynamicForward: + return []int{pfTypeInput, pfLocalPortInput, pfBindAddressInput} + default: + return []int{pfTypeInput, pfLocalPortInput, pfRemoteHostInput, pfRemotePortInput, pfBindAddressInput} + } +} + +// getNextValidField returns the next valid field index, or -1 if none +func (m *portForwardModel) getNextValidField(currentField int) int { + validFields := m.getValidFields() + + for i, field := range validFields { + if field == currentField && i < len(validFields)-1 { + return validFields[i+1] + } + } + return -1 +} + +// getPrevValidField returns the previous valid field index, or -1 if none +func (m *portForwardModel) getPrevValidField(currentField int) int { + validFields := m.getValidFields() + + for i, field := range validFields { + if field == currentField && i > 0 { + return validFields[i-1] + } + } + return -1 +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 5e888c9..78989d6 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -36,9 +36,12 @@ type Styles struct { Error lipgloss.Style // Form styles (for add/edit forms) - FormTitle lipgloss.Style - FormField lipgloss.Style - FormHelp lipgloss.Style + FormTitle lipgloss.Style + FormField lipgloss.Style + FormHelp lipgloss.Style + FormContainer lipgloss.Style + Label lipgloss.Style + FocusedLabel lipgloss.Style } // NewStyles creates a new Styles struct with the given terminal width @@ -105,6 +108,18 @@ func NewStyles(width int) Styles { FormHelp: lipgloss.NewStyle(). Foreground(lipgloss.Color("#626262")), + + FormContainer: lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(PrimaryColor)). + Padding(1, 2), + + Label: lipgloss.NewStyle(). + Foreground(lipgloss.Color(SecondaryColor)), + + FocusedLabel: lipgloss.NewStyle(). + Foreground(lipgloss.Color(PrimaryColor)). + Bold(true), } } diff --git a/internal/ui/update.go b/internal/ui/update.go index a882827..f0b207f 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -46,6 +46,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editForm.height = m.height m.editForm.styles = m.styles } + if m.portForwardForm != nil { + m.portForwardForm.width = m.width + m.portForwardForm.height = m.height + m.portForwardForm.styles = m.styles + } return m, nil case addFormSubmitMsg: @@ -136,6 +141,45 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table.Focus() return m, nil + case portForwardSubmitMsg: + if msg.err != nil { + // Show error in form + if m.portForwardForm != nil { + m.portForwardForm.err = msg.err.Error() + } + return m, nil + } else { + // Success: execute SSH command with port forwarding + if len(msg.sshArgs) > 0 { + sshCmd := exec.Command("ssh", msg.sshArgs...) + + // Record the connection in history + if m.historyManager != nil && m.portForwardForm != nil { + err := m.historyManager.RecordConnection(m.portForwardForm.hostName) + if err != nil { + fmt.Printf("Warning: Could not record connection history: %v\n", err) + } + } + + return m, tea.ExecProcess(sshCmd, func(err error) tea.Msg { + return tea.Quit() + }) + } + + // If no SSH args, just return to list view + m.viewMode = ViewList + m.portForwardForm = nil + m.table.Focus() + return m, nil + } + + case portForwardCancelMsg: + // Cancel: return to list view + m.viewMode = ViewList + m.portForwardForm = nil + m.table.Focus() + return m, nil + case tea.KeyMsg: // Handle view-specific key presses switch m.viewMode { @@ -153,6 +197,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editForm = newForm return m, cmd } + case ViewPortForward: + if m.portForwardForm != nil { + var newForm *portForwardModel + newForm, cmd = m.portForwardForm.Update(msg) + m.portForwardForm = newForm + return m, cmd + } case ViewList: // Handle list view keys return m.handleListViewKeys(msg) @@ -324,6 +375,17 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } } + case "f": + if !m.searchMode && !m.deleteMode { + // Port forwarding for the selected host + selected := m.table.SelectedRow() + if len(selected) > 0 { + hostName := selected[0] // The hostname is in the first column + m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile) + m.viewMode = ViewPortForward + return m, textinput.Blink + } + } case "s": if !m.searchMode && !m.deleteMode { // Cycle through sort modes (only 2 modes now) diff --git a/internal/ui/view.go b/internal/ui/view.go index 46b6da6..826960d 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -23,6 +23,10 @@ func (m Model) View() string { if m.editForm != nil { return m.editForm.View() } + case ViewPortForward: + if m.portForwardForm != nil { + return m.portForwardForm.View() + } case ViewList: return m.renderListView() } @@ -62,7 +66,7 @@ func (m Model) renderListView() string { // Add the help text var helpText string if !m.searchMode { - helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit" + helpText = " Use ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • (f)orward • / to search • Tab to switch\n Sort: (s)witch • (r)ecent • (n)ame • q/ESC to quit" } else { helpText = " Type to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit" }