package tui import ( "fmt" "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbletea" "github.com/mirivlad/sshkeeper/internal/model" ) // --- Forward list screen model --- type forwardScreenModel struct { serverID int64 serverAlias string list list.Model forwards []*model.Forward width int height int err error } func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel { l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, h-6) l.Title = "Port Forwards — " + serverAlias l.SetShowStatusBar(false) l.SetFilteringEnabled(false) l.Styles.Title = titleStyle return &forwardScreenModel{ serverID: serverID, serverAlias: serverAlias, list: l, width: w, height: h, } } type forwardItem struct { forward *model.Forward } func (i forwardItem) Title() string { return fmt.Sprintf("[%s] %s", i.forward.Type, forwardSummary(i.forward)) } func (i forwardItem) Description() string { return forwardPreview(i.forward) } func (i forwardItem) FilterValue() string { return string(i.forward.Type) + " " + fmt.Sprintf("%d", i.forward.LocalPort) + " " + i.forward.RemoteAddr } func forwardSummary(f *model.Forward) string { switch f.Type { case model.ForwardLocal: return fmt.Sprintf("%s:%d → %s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort) case model.ForwardRemote: return fmt.Sprintf("%s:%d ← %s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort) case model.ForwardDynamic: return fmt.Sprintf("SOCKS %s:%d", f.LocalAddr, f.LocalPort) default: return fmt.Sprintf("%s %s:%d", f.Type, f.LocalAddr, f.LocalPort) } } func forwardPreview(f *model.Forward) string { switch f.Type { case model.ForwardLocal: return fmt.Sprintf("-L %s:%d:%s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort) case model.ForwardRemote: return fmt.Sprintf("-R %s:%d:%s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort) case model.ForwardDynamic: return fmt.Sprintf("-D %s:%d", f.LocalAddr, f.LocalPort) default: return string(f.Type) } } 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) rebuildList() { items := make([]list.Item, len(m.forwards)) for i, f := range m.forwards { items[i] = forwardItem{forward: f} } m.list.SetItems(items) } func (m *forwardScreenModel) deleteSelected() tea.Cmd { if item, ok := m.list.SelectedItem().(forwardItem); ok && DeleteForward != nil { f := item.forward return func() tea.Msg { return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)} } } return nil } func (m *forwardScreenModel) View() string { var b strings.Builder b.WriteString(m.list.View()) b.WriteString("\n\n") b.WriteString(renderHelp([]helpItem{ {Key: "Ctrl+A (a)", Action: "add"}, {Key: "Ctrl+D (d)", Action: "delete"}, {Key: "Esc", Action: "back"}, }, m.width)) return b.String() } // --- Forward form screen model --- type forwardFormModel struct { serverID int64 inputs []textinput.Model labels []string focusIdx int err error saved bool typeList list.Model showList bool width int height int } func newForwardFormModel(serverID int64, w, h int) *forwardFormModel { labels := []string{"Type (local/remote/dynamic)", "Listen Addr", "Listen Port", "Target Addr", "Target Port"} inputs := make([]textinput.Model, len(labels)) for i, label := range labels { inputs[i] = textinput.New() inputs[i].Placeholder = forwardPlaceholder(label) inputs[i].CharLimit = 128 } inputs[0].Focus() typeList := newStringList([]string{"local", "remote", "dynamic"}, "Select type", 30, 8) return &forwardFormModel{ serverID: serverID, inputs: inputs, labels: labels, focusIdx: 0, typeList: typeList, width: w, height: h, } } func forwardPlaceholder(label string) string { switch label { case "Type (local/remote/dynamic)": return "local" case "Listen Addr": return "0.0.0.0" case "Listen Port": return "8080" case "Target Addr": return "internal.web" case "Target Port": return "80" default: return label } } func (fm *forwardFormModel) Init() tea.Cmd { return nil } 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 } if fm.showList { switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyEsc: fm.showList = false return fm, nil case tea.KeyEnter: if item, ok := fm.typeList.SelectedItem().(groupItem); ok { fm.inputs[0].SetValue(item.name) } fm.showList = false return fm, nil } } var cmd tea.Cmd fm.typeList, cmd = fm.typeList.Update(msg) return fm, cmd } switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyTab: fm.focusIdx++ total := len(fm.inputs) + 1 if fm.focusIdx >= total { fm.focusIdx = 0 } fm.updateFocus() return fm, nil case tea.KeyShiftTab: fm.focusIdx-- if fm.focusIdx < 0 { total := len(fm.inputs) + 1 fm.focusIdx = total - 1 } fm.updateFocus() return fm, nil case tea.KeyRunes: if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 0 { fm.showList = true return fm, nil } case tea.KeyEnter: if fm.focusIdx == len(fm.inputs) { return fm, fm.runSave() } fm.focusIdx++ fm.updateFocus() return fm, nil case tea.KeyEsc: return fm, nil case tea.KeyDown: fm.focusIdx++ total := len(fm.inputs) + 1 if fm.focusIdx >= total { fm.focusIdx = 0 } fm.updateFocus() return fm, nil case tea.KeyUp: fm.focusIdx-- if fm.focusIdx < 0 { total := len(fm.inputs) + 1 fm.focusIdx = total - 1 } fm.updateFocus() return fm, nil } } if fm.focusIdx < len(fm.inputs) { var cmd tea.Cmd fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg) return fm, cmd } return fm, nil } func (fm *forwardFormModel) updateFocus() { for i := range fm.inputs { fm.inputs[i].Blur() fm.inputs[i].Prompt = blurredStyle.Render(fm.labels[i] + ": ") } if fm.focusIdx < len(fm.inputs) { fm.inputs[fm.focusIdx].Focus() fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labels[fm.focusIdx] + "> ") } } func (fm *forwardFormModel) runSave() tea.Cmd { return func() tea.Msg { fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value())) if fwdType == "" { fwdType = model.ForwardLocal } localPort := 0 fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort) remotePort := 0 fmt.Sscanf(fm.inputs[4].Value(), "%d", &remotePort) fwd := &model.Forward{ ServerID: fm.serverID, Type: fwdType, LocalAddr: fm.inputs[1].Value(), LocalPort: localPort, RemoteAddr: fm.inputs[3].Value(), RemotePort: remotePort, } if SaveForward == nil { return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")} } if err := SaveForward(fwd); err != nil { return saveDoneMsg{err: err} } return saveDoneMsg{} } } func (fm *forwardFormModel) View() string { var b strings.Builder b.WriteString(titleStyle.Render("Add Port Forward")) b.WriteString("\n\n") for i := range fm.inputs { b.WriteString(fm.inputs[i].View()) b.WriteString("\n") if i == 0 && fm.showList { b.WriteString("\n" + renderDropdown(fm.typeList) + "\n") b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)) return b.String() } } // Preview fwdType := strings.TrimSpace(fm.inputs[0].Value()) localAddr := fm.inputs[1].Value() localPort := fm.inputs[2].Value() remoteAddr := fm.inputs[3].Value() remotePort := fm.inputs[4].Value() if fwdType != "" && localPort != "" { b.WriteString("\n" + sectionStyle.Render("Preview") + "\n") switch fwdType { case "local": b.WriteString(fmt.Sprintf(" -L %s:%s:%s:%s\n", localAddr, localPort, remoteAddr, remotePort)) case "remote": b.WriteString(fmt.Sprintf(" -R %s:%s:%s:%s\n", remoteAddr, remotePort, localAddr, localPort)) case "dynamic": b.WriteString(fmt.Sprintf(" -D %s:%s\n", localAddr, localPort)) } b.WriteString(" -o ExitOnForwardFailure=yes\n") } button := "\n[ Save ]" if fm.focusIdx == len(fm.inputs) { 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: "/", Action: "pick type"}, {Key: "Enter", Action: "save"}, {Key: "Esc", Action: "back"}, }, fm.width)) return b.String() }