feat: redesign port forward screens

This commit is contained in:
mirivlad 2026-08-14 07:54:51 +08:00
parent 762d14bb9b
commit d8ae2d4236
2 changed files with 155 additions and 121 deletions

View File

@ -79,52 +79,90 @@ func (m *forwardScreenModel) editSelected() tea.Cmd {
} }
func (m *forwardScreenModel) View() string { func (m *forwardScreenModel) View() string {
footer := renderHelp([]helpItem{ notification := ""
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Esc", Action: "back"},
}, m.width)
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine("Port Forwards — "+m.serverAlias, m.width))}
if m.err != nil { if m.err != nil {
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)), m.width)) notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
} }
body := func(width, height int) string {
footerRows := displayLineCount(footer) switch classifyTerminal(width, height) {
detailRows := 0 case sizeWide:
if len(m.list) > 0 && m.height-footerRows >= 7 { leftWidth := width * 70 / 100
detailRows = 3 rightWidth := width - leftWidth - 1
} return joinPanelColumns(
rowCapacity := max(1, m.height-len(lines)-footerRows-detailRows-1) renderPaddedPanel(leftWidth, height, m.forwardListLines(leftWidth-4, height-2, false)), leftWidth,
if len(m.list) == 0 { renderPaddedPanel(rightWidth, height, m.forwardDetailLines(rightWidth-4, false)), rightWidth,
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("No port forwards configured. Ctrl+A adds one.", m.width)))
} else {
lines = append(lines, m.renderForwardRow(nil, false))
rowCapacity--
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected))
}
if end < len(m.list) || start > 0 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
if detailRows > 0 && m.selected >= 0 && m.selected < len(m.list) {
forward := m.list[m.selected]
lines = append(lines,
sectionStyle.Copy().MarginTop(0).Render("Selected"),
fitLine(forward.ForwardHumanExplanation(m.serverAlias), m.width),
fitLine("ssh "+strings.Join(forward.ForwardSSHArgs(), " "), m.width),
) )
case sizeMedium:
detailHeight := min(7, max(4, height/3))
listHeight := max(3, height-detailHeight-1)
listPanel := renderPaddedPanel(width, listHeight, m.forwardListLines(width-4, listHeight-2, false))
detailPanel := renderPaddedPanel(width, detailHeight, m.forwardDetailLines(width-4, true))
return listPanel + "\n" + detailPanel
default:
return renderPaddedPanel(width, height, m.forwardListLines(width-4, height-2, true))
} }
} }
lines = append(lines, strings.Split(footer, "\n")...) return renderScreenShell(screenShell{
if len(lines) > m.height && m.height > 0 { breadcrumb: "Port Forwards / " + m.serverAlias,
lines = lines[:m.height] status: fmt.Sprintf("%d rules", len(m.list)),
} notification: notification,
return strings.Join(lines, "\n") width: m.width,
height: m.height,
body: body,
footer: []helpItem{
{Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"},
{Key: "Ctrl+D (d)", Action: "delete"},
{Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
} }
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool) string { func (m *forwardScreenModel) forwardListLines(width, capacity int, compact bool) []string {
if len(m.list) == 0 {
return []string{helpStyle.Copy().MarginLeft(0).Render("No port forwards configured. Ctrl+A adds one.")}
}
lines := []string{m.renderForwardRow(nil, false, width, compact)}
rowCapacity := max(1, capacity-1)
showRange := len(m.list) > rowCapacity
if showRange {
rowCapacity = max(1, rowCapacity-1)
}
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
for index := start; index < end; index++ {
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected, width, compact))
}
if showRange {
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
}
return lines
}
func (m *forwardScreenModel) forwardDetailLines(width int, compact bool) []string {
lines := []string{dashboardSection("Selected rule")}
if m.selected < 0 || m.selected >= len(m.list) {
return append(lines, "", dashboardHelp("No rule selected."))
}
forward := m.list[m.selected]
name := forward.Name
if name == "" {
name = forward.ForwardListen()
}
if compact {
lines = append(lines, name+" · "+string(forward.Type))
} else {
lines = append(lines, "", name, string(forward.Type))
}
lines = append(lines, wrapCells(forward.ForwardHumanExplanation(m.serverAlias), max(1, width))...)
if !compact {
lines = append(lines, "")
}
lines = append(lines, dashboardHelp("ssh "+strings.Join(forward.ForwardSSHArgs(), " ")))
return lines
}
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool, width int, compact bool) string {
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON" marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
if forward != nil { if forward != nil {
if selected { if selected {
@ -142,30 +180,30 @@ func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected b
enabled = "no" enabled = "no"
} }
} }
wide := m.width >= 70
typeWidth, enabledWidth := 8, 3 typeWidth, enabledWidth := 8, 3
if wide { if !compact && width >= 58 {
nameWidth := max(12, (m.width-typeWidth-enabledWidth-6)*30/100) flexible := max(3, width-typeWidth-enabledWidth-7)
listenWidth := max(14, (m.width-typeWidth-enabledWidth-nameWidth-6)/2) nameWidth := max(1, flexible*30/100)
targetWidth := m.width - nameWidth - typeWidth - listenWidth - enabledWidth - 5 listenWidth := max(1, flexible*32/100)
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth) targetWidth := max(1, flexible-nameWidth-listenWidth)
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil { if forward == nil {
return listHeaderStyle.Render(fitLine(line, m.width)) return listHeaderStyle.Render(fitLine(line, width))
} }
if selected { if selected {
return selectedRowStyle.Render(fitLine(line, m.width)) return selectedRowStyle.Render(fitLine(line, width))
} }
return fitLine(line, m.width) return fitLine(line, width)
} }
nameWidth := max(12, m.width-typeWidth-enabledWidth-4) nameWidth := max(1, width-typeWidth-enabledWidth-5)
line := marker + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth) line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
if forward == nil { if forward == nil {
return listHeaderStyle.Render(fitLine(line, m.width)) return listHeaderStyle.Render(fitLine(line, width))
} }
if selected { if selected {
return selectedRowStyle.Render(fitLine(line, m.width)) return selectedRowStyle.Render(fitLine(line, width))
} }
return fitLine(line, m.width) return fitLine(line, width)
} }
// --- Forward form screen model --- // --- Forward form screen model ---
@ -595,76 +633,69 @@ func (fm *forwardFormModel) View() string {
if fm.editMode { if fm.editMode {
title = "Edit Port Forward" title = "Edit Port Forward"
} }
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(title, fm.width))} notification := ""
lines = append(lines,
fitLine(fm.nameInput.View(), fm.width),
fitLine(fm.descInput.View(), fm.width),
)
typeParts := make([]string, len(forwardTypes))
for i, forwardType := range forwardTypes {
selected := "○"
if i == fm.typeIdx {
selected = "●"
}
focus := " "
if fm.focusIdx == 2+i {
focus = ">"
}
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
}
lines = append(lines, fitLine("Type "+strings.Join(typeParts, " "), fm.width))
if fm.width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine(forwardTypes[fm.typeIdx].description, fm.width)))
}
visible := fm.visibleFields()
for _, idx := range visible {
lines = append(lines, fitLine(fm.inputs[idx].View(), fm.width))
}
if localAddr := strings.TrimSpace(fm.inputs[0].Value()); localAddr == "0.0.0.0" {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(fitLine("⚠ This port will be accessible from the network.", fm.width)))
}
if fm.width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
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)
preview := strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
lines = append(lines, fitLine("Preview ssh "+preview, fm.width))
}
total := 2 + 3 + len(visible) + 1
button := " [ Save ]"
if fm.focusIdx == total-1 {
button = selectedStyle.Render("> [ Save ]")
}
if fm.err != nil { if fm.err != nil {
lines = append(lines, fitLine(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)), fm.width)) notification = errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
} else if fm.saved {
notification = successStyle.Render("✓ Saved.")
} }
if fm.saved { body := func(width, height int) string {
lines = append(lines, successStyle.Render("✓ Saved.")) contentWidth := max(1, width-4)
lines := []string{fm.nameInput.View(), fm.descInput.View()}
typeParts := make([]string, len(forwardTypes))
for i, forwardType := range forwardTypes {
selected := "○"
if i == fm.typeIdx {
selected = "●"
}
focus := " "
if fm.focusIdx == 2+i {
focus = ">"
}
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
}
lines = append(lines, "Type "+strings.Join(typeParts, " "))
if width >= 100 {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(forwardTypes[fm.typeIdx].description))
}
visible := fm.visibleFields()
for _, idx := range visible {
lines = append(lines, fm.inputs[idx].View())
}
if strings.TrimSpace(fm.inputs[0].Value()) == "0.0.0.0" {
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render("⚠ This port will be accessible from the network."))
}
if width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
fwd := &model.Forward{Type: fm.currentType, LocalAddr: fm.inputs[0].Value(), RemoteAddr: fm.inputs[2].Value()}
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
preview := "Preview ssh " + strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
lines = append(lines, wrapCells(preview, contentWidth)...)
}
total := 2 + 3 + len(visible) + 1
button := " [ Save ]"
if fm.focusIdx == total-1 {
button = selectedStyle.Render("> [ Save ]")
}
lines = append(lines, "", button)
return renderPaddedPanel(width, height, lines)
} }
lines = append(lines, button) return renderScreenShell(screenShell{
footer := renderHelp([]helpItem{ breadcrumb: "Port Forwards / " + title,
{Key: "Tab/↓", Action: "next"}, status: string(fm.currentType),
{Key: "↑", Action: "prev"}, notification: notification,
{Key: "1/2/3", Action: "select type"}, width: fm.width,
{Key: "Enter", Action: "save"}, height: fm.height,
{Key: "Esc", Action: "back"}, body: body,
}, fm.width) footer: []helpItem{
lines = append(lines, strings.Split(footer, "\n")...) {Key: "Tab/↓", Action: "next"},
if len(lines) > fm.height && fm.height > 0 { {Key: "↑", Action: "prev"},
lines = lines[:fm.height] {Key: "1/2/3", Action: "select type"},
} {Key: "Enter", Action: "save"},
return strings.Join(lines, "\n") {Key: "Ctrl+H", Action: "help"},
{Key: "Esc", Action: "back"},
},
})
} }
// forwardEditSignal is sent when user wants to edit a forward // forwardEditSignal is sent when user wants to edit a forward

View File

@ -107,7 +107,9 @@ func TestForwardFormFitsSupportedTerminalSizes(t *testing.T) {
fm.inputs[1].SetValue("15432") fm.inputs[1].SetValue("15432")
fm.inputs[2].SetValue("database.internal.example") fm.inputs[2].SetValue("database.internal.example")
fm.inputs[3].SetValue("5432") fm.inputs[3].SetValue("5432")
assertViewFits(t, fm.View(), size.width, size.height) view := fm.View()
assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
} }
} }
@ -120,6 +122,7 @@ func TestForwardListFitsSupportedTerminalSizes(t *testing.T) {
} }
view := fm.View() view := fm.View()
assertViewFits(t, view, size.width, size.height) assertViewFits(t, view, size.width, size.height)
assertUnifiedScreen(t, view, size.width, size.height)
for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} { for _, want := range []string{"Port Forwards", "Local PostgreSQL", "Esc"} {
if !strings.Contains(view, want) { if !strings.Contains(view, want) {
t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view) t.Fatalf("forward list at %dx%d missing %q:\n%s", size.width, size.height, want, view)