package tui import ( "fmt" "strconv" "strings" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbletea" "github.com/mirivlad/sshkeeper/internal/model" ) // --- Forward type items --- type forwardTypeItem struct { value model.ForwardType label string description string } // --- Forward list screen model --- type forwardScreenModel struct { serverID int64 serverAlias string list []*model.Forward width int height int err error selected int } func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel { return &forwardScreenModel{ serverID: serverID, serverAlias: serverAlias, width: w, height: h, } } func (m *forwardScreenModel) loadForwards() tea.Cmd { return func() tea.Msg { if ListForwards == nil { return forwardsLoadedMsg{err: fmt.Errorf("forward storage is unavailable")} } forwards, err := ListForwards(m.serverID) return forwardsLoadedMsg{forwards: forwards, err: err} } } func (m *forwardScreenModel) deleteSelected() tea.Cmd { if m.selected < 0 || m.selected >= len(m.list) { return nil } f := m.list[m.selected] return func() tea.Msg { return forwardDeleteConfirmMsg{id: f.ID, name: f.Name} } } func (m *forwardScreenModel) confirmDelete() tea.Cmd { if m.selected < 0 || m.selected >= len(m.list) { return nil } f := m.list[m.selected] return func() tea.Msg { return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)} } } func (m *forwardScreenModel) editSelected() tea.Cmd { if m.selected < 0 || m.selected >= len(m.list) { return nil } return func() tea.Msg { return forwardEditSignal{} } } func (m *forwardScreenModel) View() string { var b strings.Builder b.WriteString(titleStyle.Render("Port Forwards — " + m.serverAlias)) b.WriteString("\n\n") if len(m.list) == 0 { b.WriteString(helpStyle.Render(" No port forwards configured. Press Ctrl+A to add one.")) b.WriteString("\n") } else { // Column header b.WriteString(listHeaderStyle.Render(fmt.Sprintf(" %-22s %-8s %-20s %-20s %s", "NAME", "TYPE", "LISTEN", "TARGET", "ON"))) b.WriteString("\n") for i, f := range m.list { name := f.Name if name == "" { name = f.ForwardListen() } enabled := "yes" if !f.Enabled { enabled = "no" } line := fmt.Sprintf(" %-22s %-8s %-20s %-20s %s", truncate(name, 22), f.Type, truncate(f.ForwardListen(), 20), truncate(f.ForwardTarget(), 20), enabled, ) style := normalStyle if i == m.selected { style = selectedRowStyle } b.WriteString(style.Render(line)) b.WriteString("\n") } // Details for selected if m.selected >= 0 && m.selected < len(m.list) { f := m.list[m.selected] b.WriteString("\n") b.WriteString(sectionStyle.Render("Selected")) b.WriteString("\n") b.WriteString(fmt.Sprintf(" %s\n", f.ForwardHumanExplanation(m.serverAlias))) for _, arg := range f.ForwardSSHArgs() { b.WriteString(fmt.Sprintf(" %s\n", arg)) } } } b.WriteString("\n") b.WriteString(renderHelp([]helpItem{ {Key: "Ctrl+A (a)", Action: "add"}, {Key: "Ctrl+E/Enter", Action: "edit"}, {Key: "Ctrl+D (d)", Action: "delete"}, {Key: "Esc", Action: "back"}, }, m.width)) return b.String() } // --- Forward form screen model --- type forwardFormModel struct { serverID int64 editMode bool editID int64 inputs []textinput.Model labels []string focusIdx int err error saved bool currentType model.ForwardType nameInput textinput.Model descInput textinput.Model typeIdx int // 0=local, 1=remote, 2=socks width int height int } var forwardTypes = []forwardTypeItem{ {value: model.ForwardLocal, label: "Local", description: "port on my machine → service on SSH server"}, {value: model.ForwardRemote, label: "Remote", description: "port on SSH server → service on my machine"}, {value: model.ForwardDynamic, label: "SOCKS", description: "local dynamic SOCKS proxy through SSH"}, } func newForwardFormModel(serverID int64, w, h int) *forwardFormModel { nameInput := textinput.New() nameInput.Placeholder = "Local PostgreSQL" nameInput.CharLimit = 128 descInput := textinput.New() descInput.Placeholder = "optional" descInput.CharLimit = 256 inputs := make([]textinput.Model, 4) placeholders := []string{"127.0.0.1", "15432", "127.0.0.1", "5432"} for i := range inputs { inputs[i] = textinput.New() inputs[i].Placeholder = placeholders[i] inputs[i].CharLimit = 128 } return &forwardFormModel{ serverID: serverID, inputs: inputs, focusIdx: 0, currentType: model.ForwardLocal, typeIdx: 0, nameInput: nameInput, descInput: descInput, width: w, height: h, } } func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardFormModel { fm := newForwardFormModel(serverID, w, h) fm.editMode = true fm.editID = fwd.ID fm.nameInput.SetValue(fwd.Name) fm.descInput.SetValue(fwd.Description) fm.currentType = fwd.Type fm.typeIdx = typeIndex(fwd.Type) fm.inputs[0].SetValue(fwd.LocalAddr) fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort)) fm.inputs[2].SetValue(fwd.RemoteAddr) fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort)) return fm } func typeIndex(t model.ForwardType) int { switch t { case model.ForwardLocal: return 0 case model.ForwardRemote: return 1 case model.ForwardDynamic: return 2 } return 0 } func (fm *forwardFormModel) Init() tea.Cmd { return nil } func (fm *forwardFormModel) visibleFields() []int { switch fm.currentType { case model.ForwardLocal: return []int{0, 1, 2, 3} case model.ForwardRemote: return []int{0, 1, 2, 3} case model.ForwardDynamic: return []int{0, 1} default: return []int{0, 1, 2, 3} } } func (fm *forwardFormModel) labelForField(idx int) string { switch fm.currentType { case model.ForwardLocal: return []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}[idx] case model.ForwardRemote: return []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}[idx] case model.ForwardDynamic: return []string{"Listen Address", "Listen Port"}[idx] default: return "" } } func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case saveDoneMsg: fm.saved = (msg.err == nil) fm.err = msg.err return fm, nil } switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyTab: fm.focusIdx++ total := 2 + 3 + len(fm.visibleFields()) + 1 // name + desc + type(3) + fields + save if fm.focusIdx >= total { fm.focusIdx = 0 } fm.updateFocus() return fm, nil case tea.KeyShiftTab: fm.focusIdx-- if fm.focusIdx < 0 { total := 2 + 3 + len(fm.visibleFields()) + 1 fm.focusIdx = total - 1 } fm.updateFocus() return fm, nil case tea.KeyEnter: // Check if on type selector if fm.focusIdx >= 2 && fm.focusIdx < 2+3 { fm.typeIdx = fm.focusIdx - 2 fm.currentType = forwardTypes[fm.typeIdx].value fm.focusIdx++ fm.updateFocus() return fm, nil } if fm.focusIdx == 2+3+len(fm.visibleFields()) { return fm, fm.runSave() } fm.focusIdx++ fm.updateFocus() return fm, nil case tea.KeyEsc: return fm, nil case tea.KeyDown: fm.focusIdx++ total := 2 + 3 + len(fm.visibleFields()) + 1 if fm.focusIdx >= total { fm.focusIdx = 0 } fm.updateFocus() return fm, nil case tea.KeyUp: fm.focusIdx-- if fm.focusIdx < 0 { total := 2 + 3 + len(fm.visibleFields()) + 1 fm.focusIdx = total - 1 } fm.updateFocus() return fm, nil case tea.KeyRunes: // Direct number key to select type if len(msg.Runes) == 1 { switch msg.Runes[0] { case '1': fm.typeIdx = 0 fm.currentType = model.ForwardLocal fm.updateFocus() return fm, nil case '2': fm.typeIdx = 1 fm.currentType = model.ForwardRemote fm.updateFocus() return fm, nil case '3': fm.typeIdx = 2 fm.currentType = model.ForwardDynamic fm.updateFocus() return fm, nil } } } } // Route to focused input if fm.focusIdx == 0 { var cmd tea.Cmd fm.nameInput, cmd = fm.nameInput.Update(msg) return fm, cmd } if fm.focusIdx == 1 { var cmd tea.Cmd fm.descInput, cmd = fm.descInput.Update(msg) return fm, cmd } visible := fm.visibleFields() if fm.focusIdx >= 2+3 && fm.focusIdx < 2+3+len(visible) { fieldIdx := visible[fm.focusIdx-(2+3)] var cmd tea.Cmd fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg) return fm, cmd } return fm, nil } func (fm *forwardFormModel) updateFocus() { fm.nameInput.Blur() fm.nameInput.Prompt = blurredStyle.Render("Name: ") fm.descInput.Blur() fm.descInput.Prompt = blurredStyle.Render("Description: ") for i := range fm.inputs { fm.inputs[i].Blur() fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ") } total := 2 + 3 + len(fm.visibleFields()) + 1 switch { case fm.focusIdx == 0: fm.nameInput.Focus() fm.nameInput.Prompt = focusedStyle.Render("Name> ") case fm.focusIdx == 1: fm.descInput.Focus() fm.descInput.Prompt = focusedStyle.Render("Description> ") case fm.focusIdx >= 2 && fm.focusIdx < 2+3: // Type selector focused — no input to focus case fm.focusIdx >= 2+3 && fm.focusIdx < total-1: visible := fm.visibleFields() fieldIdx := visible[fm.focusIdx-(2+3)] fm.inputs[fieldIdx].Focus() fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ") } } func (fm *forwardFormModel) runSave() tea.Cmd { return func() tea.Msg { name := strings.TrimSpace(fm.nameInput.Value()) desc := strings.TrimSpace(fm.descInput.Value()) localPort := 0 fmt.Sscanf(fm.inputs[1].Value(), "%d", &localPort) remotePort := 0 fmt.Sscanf(fm.inputs[3].Value(), "%d", &remotePort) localAddr := strings.TrimSpace(fm.inputs[0].Value()) remoteAddr := strings.TrimSpace(fm.inputs[2].Value()) if name == "" { return saveDoneMsg{err: fmt.Errorf("name is required")} } if localPort < 1 || localPort > 65535 { return saveDoneMsg{err: fmt.Errorf("invalid listen port %d: must be 1-65535", localPort)} } switch fm.currentType { case model.ForwardLocal: if localAddr == "" { localAddr = "127.0.0.1" } if remoteAddr == "" { return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")} } if remotePort < 1 || remotePort > 65535 { return saveDoneMsg{err: fmt.Errorf("invalid target port %d: must be 1-65535", remotePort)} } case model.ForwardRemote: if remoteAddr == "" { return saveDoneMsg{err: fmt.Errorf("remote listen address is required")} } if remotePort < 1 || remotePort > 65535 { return saveDoneMsg{err: fmt.Errorf("invalid remote port %d: must be 1-65535", remotePort)} } if localAddr == "" { localAddr = "127.0.0.1" } case model.ForwardDynamic: if localAddr == "" { localAddr = "127.0.0.1" } remoteAddr = "" remotePort = 0 } fwd := &model.Forward{ ServerID: fm.serverID, Name: name, Description: desc, Type: fm.currentType, LocalAddr: localAddr, LocalPort: localPort, RemoteAddr: remoteAddr, RemotePort: remotePort, Enabled: true, } if fm.editMode { fwd.ID = fm.editID if UpdateForward == nil { return saveDoneMsg{err: fmt.Errorf("update not available")} } return saveDoneMsg{err: UpdateForward(fwd)} } if SaveForward == nil { return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")} } err := SaveForward(fwd) return saveDoneMsg{err: err} } } func (fm *forwardFormModel) View() string { var b strings.Builder title := "Add Port Forward" if fm.editMode { title = "Edit Port Forward" } b.WriteString(titleStyle.Render(title)) b.WriteString("\n\n") // Name b.WriteString(fm.nameInput.View()) b.WriteString("\n") // Description b.WriteString(fm.descInput.View()) b.WriteString("\n\n") // Type selector — visible radio items b.WriteString(sectionStyle.Render("Type")) b.WriteString("\n") for i, t := range forwardTypes { prefix := " " style := normalStyle if i == fm.typeIdx { prefix = "▸ " style = selectedRowStyle } line := fmt.Sprintf("%s%d. %-8s %s", prefix, i+1, t.label, t.description) b.WriteString(style.Render(line)) b.WriteString("\n") } b.WriteString("\n") // Dynamic fields based on type visible := fm.visibleFields() for _, idx := range visible { b.WriteString(fm.inputs[idx].View()) b.WriteString("\n") } // Warning for 0.0.0.0 if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" { b.WriteString(helpStyle.Render(" ⚠ This port will be accessible from the network.\n")) } // Preview if fm.currentType != "" && fm.inputs[1].Value() != "" { b.WriteString("\n" + sectionStyle.Render("Preview") + "\n") fwd := &model.Forward{ Type: fm.currentType, LocalAddr: fm.inputs[0].Value(), LocalPort: 0, RemoteAddr: fm.inputs[2].Value(), RemotePort: 0, } fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort) fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort) for _, arg := range fwd.ForwardSSHArgs() { b.WriteString(" " + arg + "\n") } b.WriteString(" -o ExitOnForwardFailure=yes\n") } // Save button total := 2 + 3 + len(visible) + 1 button := "\n[ Save ]" if fm.focusIdx == total-1 { button = selectedStyle.Render(button) } b.WriteString(button) b.WriteString("\n\n") if fm.err != nil { b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n") } if fm.saved { b.WriteString(successStyle.Render("✓ Saved.") + "\n\n") } b.WriteString(renderHelp([]helpItem{ {Key: "Tab/↓", Action: "next"}, {Key: "↑", Action: "prev"}, {Key: "1/2/3", Action: "select type"}, {Key: "Enter", Action: "save"}, {Key: "Esc", Action: "back"}, }, fm.width)) return b.String() } // forwardEditSignal is sent when user wants to edit a forward type forwardEditSignal struct{} // forwardDeleteConfirmMsg asks for confirmation before deleting type forwardDeleteConfirmMsg struct { id int64 name string }