From 087d7ba0dceb9aefbb100fea2a6ad4d2d7d28ad1 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 5 Jun 2026 10:00:22 +0800 Subject: [PATCH] sshkeeper: fix 4 UX issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Forward type selector: visible radio items (1.Local 2.Remote 3.SOCKS) with descriptions 2. Forward list: column header row (NAME/TYPE/LISTEN/TARGET/ON) 3. Forward delete: confirmation dialog before deletion 4. Server route column: → icon for via/chain, spaces for direct --- internal/tui/app.go | 123 +++++++++++++-- internal/tui/app_test.go | 4 +- internal/tui/forward.go | 316 ++++++++++++++++++++------------------- 3 files changed, 277 insertions(+), 166 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index aeae70c..fc603d8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -193,6 +193,7 @@ const ( screenForwardList screenForwardForm screenTunnelManager + screenConfirm ) // --- Result type — returned from TUI to caller --- @@ -234,8 +235,9 @@ type tuiModel struct { actionMenu *actionMenuModel forwardScreen *forwardScreenModel forwardForm *forwardFormModel + confirmMsg string + confirmAction func() tea.Cmd } - func New(servers []*model.Server) *tuiModel { items := make([]list.Item, len(servers)) for i, s := range servers { @@ -355,8 +357,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.forwardScreen.err = msg.err } else { - m.forwardScreen.forwards = msg.forwards - m.forwardScreen.rebuildList() + m.forwardScreen.list = msg.forwards + if len(msg.forwards) > 0 && m.forwardScreen.selected < 0 { + m.forwardScreen.selected = 0 + } } } return m, nil @@ -367,10 +371,22 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case forwardDeleteConfirmMsg: + // Show confirmation screen + m.confirmMsg = fmt.Sprintf("Delete forward %q?", msg.name) + m.confirmAction = func() tea.Cmd { + return func() tea.Msg { + return forwardDeletedMsg{id: msg.id, err: DeleteForward(msg.id)} + } + } + m.screen = screenConfirm + return m, nil + case forwardEditSignal: if m.forwardScreen != nil { - if item, ok := m.forwardScreen.list.SelectedItem().(forwardListItem); ok { - m.forwardForm = newForwardEditModel(m.forwardScreen.serverID, item.forward, m.width, m.height) + if m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { + fwd := m.forwardScreen.list[m.forwardScreen.selected] + m.forwardForm = newForwardEditModel(m.forwardScreen.serverID, fwd, m.width, m.height) m.screen = screenForwardForm } } @@ -498,6 +514,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateForwardForm(msg) case screenTunnelManager: return m.updateTunnelManager(msg) + case screenConfirm: + return m.updateConfirm(msg) } } @@ -972,6 +990,9 @@ func (m *tuiModel) View() string { if m.tunnelScreen != nil { b.WriteString(m.tunnelScreen.View()) } + + case screenConfirm: + b.WriteString(m.viewConfirm()) } if m.err != nil { @@ -1132,8 +1153,16 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } case tea.KeyCtrlD: - if m.forwardScreen != nil { - return m, m.forwardScreen.deleteSelected() + if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { + fwd := m.forwardScreen.list[m.forwardScreen.selected] + m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name) + m.confirmAction = func() tea.Cmd { + return func() tea.Msg { + return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)} + } + } + m.screen = screenConfirm + return m, nil } case tea.KeyCtrlE, tea.KeyEnter: if m.forwardScreen != nil { @@ -1148,14 +1177,30 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } case "d", "D": - if m.forwardScreen != nil { - return m, m.forwardScreen.deleteSelected() + if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { + fwd := m.forwardScreen.list[m.forwardScreen.selected] + m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name) + m.confirmAction = func() tea.Cmd { + return func() tea.Msg { + return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)} + } + } + m.screen = screenConfirm + return m, nil } } + case tea.KeyDown: + if m.forwardScreen != nil && m.forwardScreen.selected < len(m.forwardScreen.list)-1 { + m.forwardScreen.selected++ + } + return m, nil + case tea.KeyUp: + if m.forwardScreen != nil && m.forwardScreen.selected > 0 { + m.forwardScreen.selected-- + } + return m, nil } - var cmd tea.Cmd - m.forwardScreen.list, cmd = m.forwardScreen.list.Update(msg) - return m, cmd + return m, nil } func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -1189,6 +1234,52 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +func (m *tuiModel) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.screen = screenList + m.confirmMsg = "" + m.confirmAction = nil + return m, nil + case tea.KeyEnter: + if m.confirmAction != nil { + action := m.confirmAction + m.confirmMsg = "" + m.confirmAction = nil + return m, action() + } + case tea.KeyRunes: + switch msg.String() { + case "y", "Y": + if m.confirmAction != nil { + action := m.confirmAction + m.confirmMsg = "" + m.confirmAction = nil + return m, action() + } + case "n", "N": + m.screen = screenList + m.confirmMsg = "" + m.confirmAction = nil + return m, nil + } + } + return m, nil +} + +func (m *tuiModel) viewConfirm() string { + var b strings.Builder + b.WriteString(titleStyle.Render("Confirm")) + b.WriteString("\n\n") + b.WriteString(" " + m.confirmMsg) + b.WriteString("\n\n") + b.WriteString(renderHelp([]helpItem{ + {Key: "Enter / Y", Action: "yes"}, + {Key: "Esc / N", Action: "no"}, + }, m.width)) + return b.String() +} + func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyEsc { m.screen = screenForwardList @@ -1249,6 +1340,12 @@ func (m *tuiModel) viewServerList() string { } target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port) routeStr := server.Route.DisplaySummary(target) + // Add visual icon prefix based on connection type + if len(server.Route.Hops) == 0 { + routeStr = " " + routeStr // direct + } else { + routeStr = "→ " + routeStr // via/chain + } // If too long, collapse middle hops if len(routeStr) > 34 && len(server.Route.Hops) > 2 { first := server.Route.Hops[0] @@ -1256,7 +1353,7 @@ func (m *tuiModel) viewServerList() string { if !first.IsProfile { firstName = first.Raw } - routeStr = fmt.Sprintf("%s → … → %s", firstName, truncate(target, 34-len(firstName)-6)) + routeStr = fmt.Sprintf("→ %s → … → %s", firstName, truncate(target, 34-len(firstName)-8)) } group := server.GroupName if group == "" { diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 0b0ed82..51eddea 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -704,8 +704,8 @@ func TestForwardSaveSuccessReturnsToList(t *testing.T) { // Create both forwardScreen and forwardForm to simulate real flow m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, m.width, m.height) - m.forwardScreen.forwards = []*model.Forward{} - m.forwardScreen.rebuildList() + m.forwardScreen.list = []*model.Forward{} + m.forwardScreen.selected = 0 m.forwardForm = newForwardFormModel(server.ID, m.width, m.height) m.forwardForm.serverID = server.ID m.screen = screenForwardForm diff --git a/internal/tui/forward.go b/internal/tui/forward.go index 75bb6ef..92d4b7c 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -5,13 +5,12 @@ import ( "strconv" "strings" - "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbletea" "github.com/mirivlad/sshkeeper/internal/model" ) -// --- Forward type selector items --- +// --- Forward type items --- type forwardTypeItem struct { value model.ForwardType @@ -19,67 +18,27 @@ type forwardTypeItem struct { description string } -func (i forwardTypeItem) Title() string { return i.label } -func (i forwardTypeItem) Description() string { return i.description } -func (i forwardTypeItem) FilterValue() string { return i.label } - // --- Forward list screen model --- type forwardScreenModel struct { serverID int64 serverAlias string - list list.Model - forwards []*model.Forward + list []*model.Forward width int height int err error + selected int } 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 forwardListItem struct { - forward *model.Forward -} - -func (i forwardListItem) Title() string { - name := i.forward.Name - if name == "" { - name = i.forward.ForwardListen() - } - return fmt.Sprintf("%-20s %-8s %-20s %-20s %s", - truncate(name, 20), - i.forward.Type, - truncate(i.forward.ForwardListen(), 20), - truncate(i.forward.ForwardTarget(), 20), - map[bool]string{true: "yes", false: "no"}[i.forward.Enabled], - ) -} -func (i forwardListItem) Description() string { - return i.forward.ForwardHumanExplanation("") -} -func (i forwardListItem) FilterValue() string { - return i.forward.Name + " " + string(i.forward.Type) + " " + i.forward.ForwardListen() + " " + i.forward.ForwardTarget() -} - -func (m *forwardScreenModel) SetServerAlias(alias string) { - m.serverAlias = alias - m.list.Title = "Port Forwards — " + alias -} - func (m *forwardScreenModel) loadForwards() tea.Cmd { return func() tea.Msg { if ListForwards == nil { @@ -90,38 +49,88 @@ func (m *forwardScreenModel) loadForwards() tea.Cmd { } } -func (m *forwardScreenModel) rebuildList() { - items := make([]list.Item, len(m.forwards)) - for i, f := range m.forwards { - items[i] = forwardListItem{forward: f} +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} } - m.list.SetItems(items) } -func (m *forwardScreenModel) deleteSelected() tea.Cmd { - if item, ok := m.list.SelectedItem().(forwardListItem); ok && DeleteForward != nil { - f := item.forward - return func() tea.Msg { - return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)} - } +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)} } - return nil } func (m *forwardScreenModel) editSelected() tea.Cmd { - // Return signal to open edit form - if _, ok := m.list.SelectedItem().(forwardListItem); ok { - return func() tea.Msg { - return forwardEditSignal{} - } + if m.selected < 0 || m.selected >= len(m.list) { + return nil + } + return func() tea.Msg { + return forwardEditSignal{} } - return nil } func (m *forwardScreenModel) View() string { var b strings.Builder - b.WriteString(m.list.View()) + + 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"}, @@ -142,15 +151,20 @@ type forwardFormModel struct { focusIdx int err error saved bool - typeList list.Model - showList 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" @@ -161,34 +175,19 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel { descInput.CharLimit = 256 inputs := make([]textinput.Model, 4) - labels := []string{"Listen Address", "Listen Port", "Target Host", "Target Port"} placeholders := []string{"127.0.0.1", "15432", "127.0.0.1", "5432"} - for i := range labels { + for i := range inputs { inputs[i] = textinput.New() inputs[i].Placeholder = placeholders[i] inputs[i].CharLimit = 128 } - typeItems := []list.Item{ - forwardTypeItem{value: model.ForwardLocal, label: "Local", description: "port on my machine → service reachable from SSH server"}, - forwardTypeItem{value: model.ForwardRemote, label: "Remote", description: "port on SSH server → service on my machine"}, - forwardTypeItem{value: model.ForwardDynamic, label: "SOCKS", description: "local dynamic SOCKS proxy through SSH"}, - } - - typeList := list.New(typeItems, list.NewDefaultDelegate(), 50, 6) - typeList.Title = "Select forward type" - typeList.SetShowStatusBar(false) - typeList.SetShowHelp(false) - typeList.SetFilteringEnabled(false) - typeList.Styles.Title = titleStyle - return &forwardFormModel{ serverID: serverID, inputs: inputs, - labels: labels, focusIdx: 0, - typeList: typeList, currentType: model.ForwardLocal, + typeIdx: 0, nameInput: nameInput, descInput: descInput, width: w, @@ -203,24 +202,26 @@ func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardF fm.nameInput.SetValue(fwd.Name) fm.descInput.SetValue(fwd.Description) fm.currentType = fwd.Type - - switch fwd.Type { - case model.ForwardLocal: - fm.typeList.Select(0) - case model.ForwardRemote: - fm.typeList.Select(1) - case model.ForwardDynamic: - fm.typeList.Select(2) - } - + 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 } @@ -228,11 +229,11 @@ func (fm *forwardFormModel) Init() tea.Cmd { func (fm *forwardFormModel) visibleFields() []int { switch fm.currentType { case model.ForwardLocal: - return []int{0, 1, 2, 3} // listen addr/port, target host/port + return []int{0, 1, 2, 3} case model.ForwardRemote: - return []int{0, 1, 2, 3} // remote listen addr/port, local target host/port + return []int{0, 1, 2, 3} case model.ForwardDynamic: - return []int{0, 1} // listen addr/port only + return []int{0, 1} default: return []int{0, 1, 2, 3} } @@ -241,14 +242,13 @@ func (fm *forwardFormModel) visibleFields() []int { func (fm *forwardFormModel) labelForField(idx int) string { switch fm.currentType { case model.ForwardLocal: - return fm.labels[idx] + return []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}[idx] case model.ForwardRemote: - labels := []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"} - return labels[idx] + return []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}[idx] case model.ForwardDynamic: - return fm.labels[idx] + return []string{"Listen Address", "Listen Port"}[idx] default: - return fm.labels[idx] + return "" } } @@ -260,32 +260,12 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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().(forwardTypeItem); ok { - fm.currentType = item.value - } - 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 := 2 + len(fm.visibleFields()) + 1 // name + desc + fields + save btn + total := 2 + 3 + len(fm.visibleFields()) + 1 // name + desc + type(3) + fields + save if fm.focusIdx >= total { fm.focusIdx = 0 } @@ -294,18 +274,21 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyShiftTab: fm.focusIdx-- if fm.focusIdx < 0 { - total := 2 + len(fm.visibleFields()) + 1 + total := 2 + 3 + len(fm.visibleFields()) + 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 + 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 } - case tea.KeyEnter: - if fm.focusIdx == 2+len(fm.visibleFields()) { + if fm.focusIdx == 2+3+len(fm.visibleFields()) { return fm, fm.runSave() } fm.focusIdx++ @@ -315,7 +298,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return fm, nil case tea.KeyDown: fm.focusIdx++ - total := 2 + len(fm.visibleFields()) + 1 + total := 2 + 3 + len(fm.visibleFields()) + 1 if fm.focusIdx >= total { fm.focusIdx = 0 } @@ -324,11 +307,32 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyUp: fm.focusIdx-- if fm.focusIdx < 0 { - total := 2 + len(fm.visibleFields()) + 1 + 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 + } + } } } @@ -344,8 +348,8 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return fm, cmd } visible := fm.visibleFields() - if fm.focusIdx >= 2 && fm.focusIdx < 2+len(visible) { - fieldIdx := visible[fm.focusIdx-2] + 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 @@ -364,7 +368,7 @@ func (fm *forwardFormModel) updateFocus() { fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ") } - total := 2 + len(fm.visibleFields()) + 1 + total := 2 + 3 + len(fm.visibleFields()) + 1 switch { case fm.focusIdx == 0: fm.nameInput.Focus() @@ -372,9 +376,11 @@ func (fm *forwardFormModel) updateFocus() { case fm.focusIdx == 1: fm.descInput.Focus() fm.descInput.Prompt = focusedStyle.Render("Description> ") - case fm.focusIdx >= 2 && fm.focusIdx < total-1: + 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] + fieldIdx := visible[fm.focusIdx-(2+3)] fm.inputs[fieldIdx].Focus() fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ") } @@ -466,26 +472,28 @@ func (fm *forwardFormModel) View() string { b.WriteString(titleStyle.Render(title)) b.WriteString("\n\n") - // Type selector - typeLabel := fmt.Sprintf("Type: %s (/ to change)", fm.currentType) - if fm.focusIdx == 0 { - typeLabel = focusedStyle.Render("Type> " + fmt.Sprintf("%s (/ to change)", fm.currentType)) - } - b.WriteString(typeLabel) - b.WriteString("\n") - - if fm.showList { - b.WriteString("\n" + fm.typeList.View() + "\n") - b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width)) - return b.String() - } - // 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 @@ -519,7 +527,7 @@ func (fm *forwardFormModel) View() string { } // Save button - total := 2 + len(visible) + 1 + total := 2 + 3 + len(visible) + 1 button := "\n[ Save ]" if fm.focusIdx == total-1 { button = selectedStyle.Render(button) @@ -537,7 +545,7 @@ func (fm *forwardFormModel) View() string { b.WriteString(renderHelp([]helpItem{ {Key: "Tab/↓", Action: "next"}, {Key: "↑", Action: "prev"}, - {Key: "/", Action: "change type"}, + {Key: "1/2/3", Action: "select type"}, {Key: "Enter", Action: "save"}, {Key: "Esc", Action: "back"}, }, fm.width)) @@ -547,3 +555,9 @@ func (fm *forwardFormModel) View() 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 +}