sshkeeper: fix 4 UX issues

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
This commit is contained in:
mirivlad 2026-06-05 10:00:22 +08:00
parent 6cf281c349
commit 087d7ba0dc
3 changed files with 277 additions and 166 deletions

View File

@ -193,6 +193,7 @@ const (
screenForwardList screenForwardList
screenForwardForm screenForwardForm
screenTunnelManager screenTunnelManager
screenConfirm
) )
// --- Result type — returned from TUI to caller --- // --- Result type — returned from TUI to caller ---
@ -234,8 +235,9 @@ type tuiModel struct {
actionMenu *actionMenuModel actionMenu *actionMenuModel
forwardScreen *forwardScreenModel forwardScreen *forwardScreenModel
forwardForm *forwardFormModel forwardForm *forwardFormModel
confirmMsg string
confirmAction func() tea.Cmd
} }
func New(servers []*model.Server) *tuiModel { func New(servers []*model.Server) *tuiModel {
items := make([]list.Item, len(servers)) items := make([]list.Item, len(servers))
for i, s := range 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 { if msg.err != nil {
m.forwardScreen.err = msg.err m.forwardScreen.err = msg.err
} else { } else {
m.forwardScreen.forwards = msg.forwards m.forwardScreen.list = msg.forwards
m.forwardScreen.rebuildList() if len(msg.forwards) > 0 && m.forwardScreen.selected < 0 {
m.forwardScreen.selected = 0
}
} }
} }
return m, nil return m, nil
@ -367,10 +371,22 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil 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: case forwardEditSignal:
if m.forwardScreen != nil { if m.forwardScreen != nil {
if item, ok := m.forwardScreen.list.SelectedItem().(forwardListItem); ok { if m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
m.forwardForm = newForwardEditModel(m.forwardScreen.serverID, item.forward, m.width, m.height) fwd := m.forwardScreen.list[m.forwardScreen.selected]
m.forwardForm = newForwardEditModel(m.forwardScreen.serverID, fwd, m.width, m.height)
m.screen = screenForwardForm m.screen = screenForwardForm
} }
} }
@ -498,6 +514,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateForwardForm(msg) return m.updateForwardForm(msg)
case screenTunnelManager: case screenTunnelManager:
return m.updateTunnelManager(msg) return m.updateTunnelManager(msg)
case screenConfirm:
return m.updateConfirm(msg)
} }
} }
@ -972,6 +990,9 @@ func (m *tuiModel) View() string {
if m.tunnelScreen != nil { if m.tunnelScreen != nil {
b.WriteString(m.tunnelScreen.View()) b.WriteString(m.tunnelScreen.View())
} }
case screenConfirm:
b.WriteString(m.viewConfirm())
} }
if m.err != nil { if m.err != nil {
@ -1132,8 +1153,16 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
case tea.KeyCtrlD: case tea.KeyCtrlD:
if m.forwardScreen != nil { if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
return m, m.forwardScreen.deleteSelected() 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: case tea.KeyCtrlE, tea.KeyEnter:
if m.forwardScreen != nil { if m.forwardScreen != nil {
@ -1148,14 +1177,30 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
case "d", "D": case "d", "D":
if m.forwardScreen != nil { if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
return m, m.forwardScreen.deleteSelected() 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
} }
var cmd tea.Cmd }
m.forwardScreen.list, cmd = m.forwardScreen.list.Update(msg) case tea.KeyDown:
return m, cmd 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
}
return m, nil
} }
func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 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) { func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyEsc { if msg.Type == tea.KeyEsc {
m.screen = screenForwardList m.screen = screenForwardList
@ -1249,6 +1340,12 @@ func (m *tuiModel) viewServerList() string {
} }
target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port) target := fmt.Sprintf("%s@%s:%d", server.User, server.Host, server.Port)
routeStr := server.Route.DisplaySummary(target) 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 too long, collapse middle hops
if len(routeStr) > 34 && len(server.Route.Hops) > 2 { if len(routeStr) > 34 && len(server.Route.Hops) > 2 {
first := server.Route.Hops[0] first := server.Route.Hops[0]
@ -1256,7 +1353,7 @@ func (m *tuiModel) viewServerList() string {
if !first.IsProfile { if !first.IsProfile {
firstName = first.Raw 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 group := server.GroupName
if group == "" { if group == "" {

View File

@ -704,8 +704,8 @@ func TestForwardSaveSuccessReturnsToList(t *testing.T) {
// Create both forwardScreen and forwardForm to simulate real flow // Create both forwardScreen and forwardForm to simulate real flow
m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, m.width, m.height) m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, m.width, m.height)
m.forwardScreen.forwards = []*model.Forward{} m.forwardScreen.list = []*model.Forward{}
m.forwardScreen.rebuildList() m.forwardScreen.selected = 0
m.forwardForm = newForwardFormModel(server.ID, m.width, m.height) m.forwardForm = newForwardFormModel(server.ID, m.width, m.height)
m.forwardForm.serverID = server.ID m.forwardForm.serverID = server.ID
m.screen = screenForwardForm m.screen = screenForwardForm

View File

@ -5,13 +5,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model" "github.com/mirivlad/sshkeeper/internal/model"
) )
// --- Forward type selector items --- // --- Forward type items ---
type forwardTypeItem struct { type forwardTypeItem struct {
value model.ForwardType value model.ForwardType
@ -19,67 +18,27 @@ type forwardTypeItem struct {
description string 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 --- // --- Forward list screen model ---
type forwardScreenModel struct { type forwardScreenModel struct {
serverID int64 serverID int64
serverAlias string serverAlias string
list list.Model list []*model.Forward
forwards []*model.Forward
width int width int
height int height int
err error err error
selected int
} }
func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel { 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{ return &forwardScreenModel{
serverID: serverID, serverID: serverID,
serverAlias: serverAlias, serverAlias: serverAlias,
list: l,
width: w, width: w,
height: h, 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 { func (m *forwardScreenModel) loadForwards() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if ListForwards == nil { if ListForwards == nil {
@ -90,38 +49,88 @@ func (m *forwardScreenModel) loadForwards() tea.Cmd {
} }
} }
func (m *forwardScreenModel) rebuildList() { func (m *forwardScreenModel) deleteSelected() tea.Cmd {
items := make([]list.Item, len(m.forwards)) if m.selected < 0 || m.selected >= len(m.list) {
for i, f := range m.forwards { return nil
items[i] = forwardListItem{forward: f} }
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 { func (m *forwardScreenModel) confirmDelete() tea.Cmd {
if item, ok := m.list.SelectedItem().(forwardListItem); ok && DeleteForward != nil { if m.selected < 0 || m.selected >= len(m.list) {
f := item.forward return nil
}
f := m.list[m.selected]
return func() tea.Msg { return func() tea.Msg {
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)} return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
} }
}
return nil
} }
func (m *forwardScreenModel) editSelected() tea.Cmd { func (m *forwardScreenModel) editSelected() tea.Cmd {
// Return signal to open edit form if m.selected < 0 || m.selected >= len(m.list) {
if _, ok := m.list.SelectedItem().(forwardListItem); ok { return nil
}
return func() tea.Msg { return func() tea.Msg {
return forwardEditSignal{} return forwardEditSignal{}
} }
}
return nil
} }
func (m *forwardScreenModel) View() string { func (m *forwardScreenModel) View() string {
var b strings.Builder var b strings.Builder
b.WriteString(m.list.View())
b.WriteString(titleStyle.Render("Port Forwards — " + m.serverAlias))
b.WriteString("\n\n") 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{ b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"}, {Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"}, {Key: "Ctrl+E/Enter", Action: "edit"},
@ -142,15 +151,20 @@ type forwardFormModel struct {
focusIdx int focusIdx int
err error err error
saved bool saved bool
typeList list.Model
showList bool
currentType model.ForwardType currentType model.ForwardType
nameInput textinput.Model nameInput textinput.Model
descInput textinput.Model descInput textinput.Model
typeIdx int // 0=local, 1=remote, 2=socks
width int width int
height 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 { func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
nameInput := textinput.New() nameInput := textinput.New()
nameInput.Placeholder = "Local PostgreSQL" nameInput.Placeholder = "Local PostgreSQL"
@ -161,34 +175,19 @@ func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
descInput.CharLimit = 256 descInput.CharLimit = 256
inputs := make([]textinput.Model, 4) 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"} 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] = textinput.New()
inputs[i].Placeholder = placeholders[i] inputs[i].Placeholder = placeholders[i]
inputs[i].CharLimit = 128 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{ return &forwardFormModel{
serverID: serverID, serverID: serverID,
inputs: inputs, inputs: inputs,
labels: labels,
focusIdx: 0, focusIdx: 0,
typeList: typeList,
currentType: model.ForwardLocal, currentType: model.ForwardLocal,
typeIdx: 0,
nameInput: nameInput, nameInput: nameInput,
descInput: descInput, descInput: descInput,
width: w, width: w,
@ -203,24 +202,26 @@ func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardF
fm.nameInput.SetValue(fwd.Name) fm.nameInput.SetValue(fwd.Name)
fm.descInput.SetValue(fwd.Description) fm.descInput.SetValue(fwd.Description)
fm.currentType = fwd.Type fm.currentType = fwd.Type
fm.typeIdx = typeIndex(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.inputs[0].SetValue(fwd.LocalAddr) fm.inputs[0].SetValue(fwd.LocalAddr)
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort)) fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
fm.inputs[2].SetValue(fwd.RemoteAddr) fm.inputs[2].SetValue(fwd.RemoteAddr)
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort)) fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
return fm 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 { func (fm *forwardFormModel) Init() tea.Cmd {
return nil return nil
} }
@ -228,11 +229,11 @@ func (fm *forwardFormModel) Init() tea.Cmd {
func (fm *forwardFormModel) visibleFields() []int { func (fm *forwardFormModel) visibleFields() []int {
switch fm.currentType { switch fm.currentType {
case model.ForwardLocal: case model.ForwardLocal:
return []int{0, 1, 2, 3} // listen addr/port, target host/port return []int{0, 1, 2, 3}
case model.ForwardRemote: 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: case model.ForwardDynamic:
return []int{0, 1} // listen addr/port only return []int{0, 1}
default: default:
return []int{0, 1, 2, 3} return []int{0, 1, 2, 3}
} }
@ -241,14 +242,13 @@ func (fm *forwardFormModel) visibleFields() []int {
func (fm *forwardFormModel) labelForField(idx int) string { func (fm *forwardFormModel) labelForField(idx int) string {
switch fm.currentType { switch fm.currentType {
case model.ForwardLocal: case model.ForwardLocal:
return fm.labels[idx] return []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}[idx]
case model.ForwardRemote: case model.ForwardRemote:
labels := []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"} return []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}[idx]
return labels[idx]
case model.ForwardDynamic: case model.ForwardDynamic:
return fm.labels[idx] return []string{"Listen Address", "Listen Port"}[idx]
default: default:
return fm.labels[idx] return ""
} }
} }
@ -260,32 +260,12 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil 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) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.Type { switch msg.Type {
case tea.KeyTab: case tea.KeyTab:
fm.focusIdx++ 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 { if fm.focusIdx >= total {
fm.focusIdx = 0 fm.focusIdx = 0
} }
@ -294,18 +274,21 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyShiftTab: case tea.KeyShiftTab:
fm.focusIdx-- fm.focusIdx--
if fm.focusIdx < 0 { if fm.focusIdx < 0 {
total := 2 + len(fm.visibleFields()) + 1 total := 2 + 3 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1 fm.focusIdx = total - 1
} }
fm.updateFocus() fm.updateFocus()
return fm, nil return fm, nil
case tea.KeyRunes: case tea.KeyEnter:
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 0 { // Check if on type selector
fm.showList = true 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 return fm, nil
} }
case tea.KeyEnter: if fm.focusIdx == 2+3+len(fm.visibleFields()) {
if fm.focusIdx == 2+len(fm.visibleFields()) {
return fm, fm.runSave() return fm, fm.runSave()
} }
fm.focusIdx++ fm.focusIdx++
@ -315,7 +298,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil return fm, nil
case tea.KeyDown: case tea.KeyDown:
fm.focusIdx++ fm.focusIdx++
total := 2 + len(fm.visibleFields()) + 1 total := 2 + 3 + len(fm.visibleFields()) + 1
if fm.focusIdx >= total { if fm.focusIdx >= total {
fm.focusIdx = 0 fm.focusIdx = 0
} }
@ -324,11 +307,32 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyUp: case tea.KeyUp:
fm.focusIdx-- fm.focusIdx--
if fm.focusIdx < 0 { if fm.focusIdx < 0 {
total := 2 + len(fm.visibleFields()) + 1 total := 2 + 3 + len(fm.visibleFields()) + 1
fm.focusIdx = total - 1 fm.focusIdx = total - 1
} }
fm.updateFocus() fm.updateFocus()
return fm, nil 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 return fm, cmd
} }
visible := fm.visibleFields() visible := fm.visibleFields()
if fm.focusIdx >= 2 && fm.focusIdx < 2+len(visible) { if fm.focusIdx >= 2+3 && fm.focusIdx < 2+3+len(visible) {
fieldIdx := visible[fm.focusIdx-2] fieldIdx := visible[fm.focusIdx-(2+3)]
var cmd tea.Cmd var cmd tea.Cmd
fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg) fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg)
return fm, cmd return fm, cmd
@ -364,7 +368,7 @@ func (fm *forwardFormModel) updateFocus() {
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ") fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ")
} }
total := 2 + len(fm.visibleFields()) + 1 total := 2 + 3 + len(fm.visibleFields()) + 1
switch { switch {
case fm.focusIdx == 0: case fm.focusIdx == 0:
fm.nameInput.Focus() fm.nameInput.Focus()
@ -372,9 +376,11 @@ func (fm *forwardFormModel) updateFocus() {
case fm.focusIdx == 1: case fm.focusIdx == 1:
fm.descInput.Focus() fm.descInput.Focus()
fm.descInput.Prompt = focusedStyle.Render("Description> ") 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() visible := fm.visibleFields()
fieldIdx := visible[fm.focusIdx-2] fieldIdx := visible[fm.focusIdx-(2+3)]
fm.inputs[fieldIdx].Focus() fm.inputs[fieldIdx].Focus()
fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ") 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(titleStyle.Render(title))
b.WriteString("\n\n") 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 // Name
b.WriteString(fm.nameInput.View()) b.WriteString(fm.nameInput.View())
b.WriteString("\n") b.WriteString("\n")
// Description // Description
b.WriteString(fm.descInput.View()) 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") b.WriteString("\n")
// Dynamic fields based on type // Dynamic fields based on type
@ -519,7 +527,7 @@ func (fm *forwardFormModel) View() string {
} }
// Save button // Save button
total := 2 + len(visible) + 1 total := 2 + 3 + len(visible) + 1
button := "\n[ Save ]" button := "\n[ Save ]"
if fm.focusIdx == total-1 { if fm.focusIdx == total-1 {
button = selectedStyle.Render(button) button = selectedStyle.Render(button)
@ -537,7 +545,7 @@ func (fm *forwardFormModel) View() string {
b.WriteString(renderHelp([]helpItem{ b.WriteString(renderHelp([]helpItem{
{Key: "Tab/↓", Action: "next"}, {Key: "Tab/↓", Action: "next"},
{Key: "↑", Action: "prev"}, {Key: "↑", Action: "prev"},
{Key: "/", Action: "change type"}, {Key: "1/2/3", Action: "select type"},
{Key: "Enter", Action: "save"}, {Key: "Enter", Action: "save"},
{Key: "Esc", Action: "back"}, {Key: "Esc", Action: "back"},
}, fm.width)) }, fm.width))
@ -547,3 +555,9 @@ func (fm *forwardFormModel) View() string {
// forwardEditSignal is sent when user wants to edit a forward // forwardEditSignal is sent when user wants to edit a forward
type forwardEditSignal struct{} type forwardEditSignal struct{}
// forwardDeleteConfirmMsg asks for confirmation before deleting
type forwardDeleteConfirmMsg struct {
id int64
name string
}