fix: address tui redesign review findings

This commit is contained in:
mirivlad 2026-08-14 04:04:10 +08:00
parent dac7c7d37a
commit be800de90b
13 changed files with 461 additions and 55 deletions

View File

@ -96,20 +96,20 @@ Running `sshkeeper` without arguments opens the TUI.
### Main Window
```
sshkeeper 0 servers
Vault unlocked | 0 OK | 0 FAIL
NAME ALIAS ROUTE AUTH GROUP STATUS
No servers yet. Press Ctrl+A to add one.
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit
Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
sshkeeper / Servers Vault unlocked · 1 profiles
────────────────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────────────────────────────────────────────┐
│1 servers │
│ NAME AUTH GROUP STATUS │
│> Production agent - ? │
└──────────────────────────────────────────────────────────────────────────────┘
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit | Ctrl+Q: quit
```
### Quick Help (?)
Press `?` on any screen for a compact hotkey reference.
Press `?` outside text editors for a compact hotkey reference. Inside forms and
search, `?` remains normal text input.
### Full Help (F1)

View File

@ -173,6 +173,9 @@ Auth: agent Group: - Status: ?
| `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход |
`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые
изменения, сначала открывается безопасное подтверждение с выбранным Cancel.
### Быстрая справка по клавишам
Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -129,4 +129,3 @@
- [ ] Resolve every Critical or Important issue using a failing regression test first.
- [ ] Re-run `go vet ./...`, `go test ./... -count=1`, `go build ./...`, and the three-size runtime capture.
- [ ] Verify feature branch is clean and synchronized, fast-forward `main`, push `main` to `origin` and `github`, and verify all three refs resolve to the same SHA.

View File

@ -110,4 +110,3 @@ context, vault status, resize propagation, and Unicode truncation.
Runtime checks use an isolated XDG profile and real Bubble Tea execution at
120x40, 80x24, and 60x16. Each size is captured and visually inspected for
clipping, missing focus, missing actions, and false status.

View File

@ -62,11 +62,15 @@ type saveDoneMsg struct {
type templatesLoadedMsg struct {
templates []*model.CommandTemplate
deleted bool
deletedName string
err error
}
type tagsLoadedMsg struct {
tags []string
deleted bool
deletedName string
err error
}
@ -97,9 +101,12 @@ type forwardDeletedMsg struct {
type serverDeletedMsg struct {
alias string
servers []*model.Server
deleted bool
err error
}
type quitAfterDiscardMsg struct{}
type discardFormMsg struct {
origin screen
}
@ -397,7 +404,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.finishConfirm()
}
if msg.err != nil {
if msg.deleted {
m.removeTemplate(msg.deletedName)
m.err = nil
m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.deletedName, msg.err)
} else {
m.err = msg.err
}
return m, nil
}
m.setTemplates(msg.templates)
@ -408,7 +421,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.finishConfirm()
}
if msg.err != nil {
if msg.deleted {
m.removeTag(msg.deletedName)
m.err = nil
m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.deletedName, msg.err)
} else {
m.err = msg.err
}
return m, nil
}
m.setTags(msg.tags)
@ -474,14 +493,32 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.forwardScreen.err = nil
forwards := make([]*model.Forward, 0, len(m.forwardScreen.list))
for _, forward := range m.forwardScreen.list {
if forward.ID != msg.id {
forwards = append(forwards, forward)
}
}
m.forwardScreen.list = forwards
if m.forwardScreen.selected >= len(forwards) {
m.forwardScreen.selected = max(0, len(forwards)-1)
}
return m, m.forwardScreen.loadForwards()
}
return m, nil
case serverDeletedMsg:
m.finishConfirm()
if msg.deleted {
m.removeServer(msg.alias)
}
if msg.err != nil {
if msg.deleted {
m.err = nil
m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.alias, msg.err)
} else {
m.err = msg.err
}
return m, nil
}
m.servers = msg.servers
@ -493,6 +530,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
delete(m.selected, msg.alias)
return m, nil
case quitAfterDiscardMsg:
m.confirm = nil
m.form = nil
m.forwardForm = nil
m.templateForm = nil
return m, tea.Quit
case discardFormMsg:
m.finishConfirm()
switch msg.origin {
@ -588,7 +632,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case saveDoneMsg:
if m.forwardForm != nil {
if msg.err != nil {
m.forwardForm.err = msg.err
m.forwardForm.applySaveError(msg.err)
m.forwardForm.saved = false
// Stay on screenForwardForm to show error
return m, nil
@ -619,12 +663,14 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.form != nil {
m.form.saving = false
if msg.err != nil {
m.form.err = msg.err
m.form.applySaveError(msg.err)
m.form.saved = false
} else {
m.form.saved = true
m.form.savedTime = time.Now()
m.form.err = nil
m.form.password.SetValue("")
m.form.initial = m.form.snapshot()
}
}
return m, nil
@ -640,6 +686,9 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.screen = screenFullHelp
return m, nil
}
if msg.Type == tea.KeyCtrlQ && m.screen != screenConfirm {
return m.requestQuit()
}
if msg.Type == tea.KeyRunes && msg.String() == "?" && !m.screenOwnsPrintableInput() && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
m.helpParent = m.screen
m.helpScreen = newHelpScreenModel(m.width, m.height)
@ -790,6 +839,33 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *tuiModel) requestQuit() (tea.Model, tea.Cmd) {
dirty := false
switch m.screen {
case screenForm:
dirty = m.form != nil && m.form.Dirty()
case screenForwardForm:
dirty = m.forwardForm != nil && m.forwardForm.Dirty()
case screenTemplateForm:
dirty = m.templateForm != nil && m.templateForm.Dirty()
}
if !dirty {
return m, tea.Quit
}
origin := m.screen
m.beginConfirm(confirmState{
title: "Discard changes and quit?",
target: "Unsaved form changes",
consequence: "Your edits will be lost before sshkeeper exits.",
verb: "Quit",
parent: origin,
action: func() tea.Cmd {
return func() tea.Msg { return quitAfterDiscardMsg{} }
},
})
return m, nil
}
func (m *tuiModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
@ -859,10 +935,10 @@ func (m *tuiModel) updateTags(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return tagsLoadedMsg{err: err}
}
if ListTags == nil {
return tagsLoadedMsg{err: fmt.Errorf("tag reload is unavailable")}
return tagsLoadedMsg{deleted: true, deletedName: name, err: fmt.Errorf("tag reload is unavailable")}
}
tags, err := ListTags()
return tagsLoadedMsg{tags: tags, err: err}
return tagsLoadedMsg{tags: tags, deleted: true, deletedName: name, err: err}
}
},
})
@ -968,10 +1044,10 @@ func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return templatesLoadedMsg{err: err}
}
if ListCommandTemplates == nil {
return templatesLoadedMsg{err: fmt.Errorf("template reload is unavailable")}
return templatesLoadedMsg{deleted: true, deletedName: name, err: fmt.Errorf("template reload is unavailable")}
}
templates, err := ListCommandTemplates()
return templatesLoadedMsg{templates: templates, err: err}
return templatesLoadedMsg{templates: templates, deleted: true, deletedName: name, err: err}
}
},
})
@ -1473,14 +1549,22 @@ func (m *tuiModel) viewConfirm() string {
if m.confirm == nil {
return ""
}
var b strings.Builder
b.WriteString(titleStyle.Render(m.confirm.title))
b.WriteString("\n\n")
b.WriteString(" " + m.confirm.target)
if m.confirm.consequence != "" {
b.WriteString("\n\n " + m.confirm.consequence)
width := m.width
if width <= 0 {
width = 80
}
b.WriteString("\n\n")
innerWidth := max(1, width-2)
lines := []string{titleStyle.Copy().MarginLeft(0).Render(fitLine(m.confirm.title, width)), ""}
for _, line := range wrapCells(m.confirm.target, innerWidth) {
lines = append(lines, " "+line)
}
if m.confirm.consequence != "" {
lines = append(lines, "")
for _, line := range wrapCells(m.confirm.consequence, innerWidth) {
lines = append(lines, " "+line)
}
}
lines = append(lines, "")
cancel := "[ Cancel ]"
accept := "[ " + m.confirm.verb + " ]"
if m.confirm.focus == confirmCancel {
@ -1489,16 +1573,17 @@ func (m *tuiModel) viewConfirm() string {
accept = errorStyle.Render("> " + accept)
}
if m.confirm.pending {
b.WriteString(" " + m.confirm.verb + " in progress…\n\n")
lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "")
} else {
b.WriteString(" " + cancel + " " + accept + "\n\n")
lines = append(lines, fitLine(" "+cancel+" "+accept, width), "")
}
b.WriteString(renderHelp([]helpItem{
footer := renderHelp([]helpItem{
{Key: "Tab", Action: "choose"},
{Key: "Enter", Action: "activate"},
{Key: "Esc", Action: "cancel"},
}, m.width))
return b.String()
}, width)
lines = append(lines, strings.Split(footer, "\n")...)
return strings.Join(lines, "\n")
}
func (m *tuiModel) beginConfirm(state confirmState) {
@ -1566,15 +1651,31 @@ func (m *tuiModel) confirmServerDelete(server *model.Server) {
return serverDeletedMsg{alias: alias, err: err}
}
if ListServers == nil {
return serverDeletedMsg{alias: alias, err: fmt.Errorf("server reload is unavailable")}
return serverDeletedMsg{alias: alias, deleted: true, err: fmt.Errorf("server reload is unavailable")}
}
servers, err := ListServers()
return serverDeletedMsg{alias: alias, servers: servers, err: err}
return serverDeletedMsg{alias: alias, servers: servers, deleted: true, err: err}
}
},
})
}
func (m *tuiModel) removeServer(alias string) {
servers := make([]*model.Server, 0, len(m.servers))
for _, server := range m.servers {
if server.Alias != alias {
servers = append(servers, server)
}
}
m.servers = servers
items := make([]list.Item, len(servers))
for index, server := range servers {
items[index] = serverItem{server: server}
}
m.list.SetItems(items)
delete(m.selected, alias)
}
func (m *tuiModel) confirmForwardDelete(fwd *model.Forward) {
name := fwd.Name
if strings.TrimSpace(name) == "" {
@ -2010,6 +2111,26 @@ func (m *tuiModel) setTags(tags []string) {
m.tagList = newStringList(tags, "Tags", m.width, managerListHeight(m.height))
}
func (m *tuiModel) removeTemplate(name string) {
templates := make([]*model.CommandTemplate, 0, len(m.templates))
for _, template := range m.templates {
if template.Name != name {
templates = append(templates, template)
}
}
m.setTemplates(templates)
}
func (m *tuiModel) removeTag(name string) {
tags := make([]string, 0, len(m.tags))
for _, tag := range m.tags {
if tag != name {
tags = append(tags, tag)
}
}
m.setTags(tags)
}
// --- Server list footer ---
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string {

View File

@ -82,6 +82,72 @@ func TestServerDeleteRunsOnceAndReturnsToList(t *testing.T) {
}
}
func TestServerDeletePartialSuccessRemovesLocalRow(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22}
m := New([]*model.Server{server})
oldDelete, oldList := DeleteServer, ListServers
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
DeleteServer = func(string) error { return nil }
ListServers = func() ([]*model.Server, error) { return nil, errors.New("reload unavailable") }
m.confirmServerDelete(server)
m.confirm.focus = confirmAccept
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if len(m.servers) != 0 || len(m.list.Items()) != 0 {
t.Fatalf("deleted server remained visible: servers=%d items=%d", len(m.servers), len(m.list.Items()))
}
if m.err != nil || !strings.Contains(m.success, "refresh failed") {
t.Fatalf("partial success was misreported: err=%v success=%q", m.err, m.success)
}
}
func TestTagAndTemplateDeletePartialSuccessRemovesLocalRow(t *testing.T) {
t.Run("tag", func(t *testing.T) {
m := New(nil)
m.screen = screenTags
m.setTags([]string{"prod"})
oldDelete, oldList := DeleteTag, ListTags
t.Cleanup(func() { DeleteTag, ListTags = oldDelete, oldList })
DeleteTag = func(string) error { return nil }
ListTags = func() ([]string, error) { return nil, errors.New("reload unavailable") }
updated, _ := m.updateTags(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
m.confirm.focus = confirmAccept
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if len(m.tags) != 0 || !strings.Contains(m.success, "refresh failed") {
t.Fatalf("tag partial success: tags=%v success=%q err=%v", m.tags, m.success, m.err)
}
})
t.Run("template", func(t *testing.T) {
m := New(nil)
m.screen = screenTemplates
m.setTemplates([]*model.CommandTemplate{{Name: "uptime", Command: "uptime"}})
oldDelete, oldList := DeleteCommandTemplate, ListCommandTemplates
t.Cleanup(func() { DeleteCommandTemplate, ListCommandTemplates = oldDelete, oldList })
DeleteCommandTemplate = func(string) error { return nil }
ListCommandTemplates = func() ([]*model.CommandTemplate, error) { return nil, errors.New("reload unavailable") }
updated, _ := m.updateTemplates(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
m.confirm.focus = confirmAccept
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if len(m.templates) != 0 || !strings.Contains(m.success, "refresh failed") {
t.Fatalf("template partial success: templates=%v success=%q err=%v", m.templates, m.success, m.err)
}
})
}
func TestForwardDeleteReturnsToForwardListAndRetainsError(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod"}
fwd := &model.Forward{ID: 7, ServerID: 1, Name: "postgres", Type: model.ForwardLocal, LocalAddr: "127.0.0.1", LocalPort: 15432, RemoteAddr: "db", RemotePort: 5432, Enabled: true}

View File

@ -257,7 +257,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case saveDoneMsg:
fm.saving = false
if msg.err != nil {
fm.err = msg.err
fm.applySaveError(msg.err)
fm.saved = false
} else {
fm.saved = true
@ -401,6 +401,25 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil
}
func (fm *formModel) applySaveError(err error) {
fm.err = err
if err == nil {
return
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "alias is required"):
fm.focusIdx = 0
case strings.Contains(message, "host is required"):
fm.focusIdx = 2
case strings.Contains(message, "port"):
fm.focusIdx = 3
default:
return
}
fm.updateFocus()
}
func (fm *formModel) updateFocus() {
for i := range fm.inputs {
fm.inputs[i].Blur()

View File

@ -46,11 +46,91 @@ func TestServerFormPreservesInvalidPortAndDoesNotSave(t *testing.T) {
if fm.err == nil || !strings.Contains(fm.err.Error(), "Port") {
t.Fatalf("missing actionable port error: %v", fm.err)
}
if fm.focusIdx != 3 {
t.Fatalf("invalid port focus = %d, want 3", fm.focusIdx)
}
if view := fm.View(); !strings.Contains(view, "Port must be a number") {
t.Fatalf("validation error is not rendered:\n%s", view)
}
}
func TestServerSaveSuccessResetsDirtySnapshot(t *testing.T) {
m := New(nil)
m.screen = screenForm
m.form = newFormModel(80, 24)
m.form.inputs[0].SetValue("prod")
m.form.inputs[2].SetValue("prod.example")
m.form.password.SetValue("secret")
updated, _ := m.Update(saveDoneMsg{})
m = updated.(*tuiModel)
if m.form == nil || m.form.Dirty() {
t.Fatalf("saved form remains dirty: %#v", m.form)
}
if m.form.password.Value() != "" {
t.Fatal("saved secret remained in the form")
}
}
func TestForwardValidationMovesFocusToInvalidPort(t *testing.T) {
fm := newForwardFormModel(1, 60, 16)
fm.nameInput.SetValue("postgres")
fm.inputs[0].SetValue("127.0.0.1")
fm.inputs[1].SetValue("bad")
fm.inputs[2].SetValue("db")
fm.inputs[3].SetValue("5432")
updated, _ := fm.Update(fm.runSave()())
fm = updated.(*forwardFormModel)
if fm.focusIdx != 6 {
t.Fatalf("invalid listen port focus = %d, want 6", fm.focusIdx)
}
view := fm.View()
if !strings.Contains(view, "Listen Port") || !strings.Contains(view, "must be a number") {
t.Fatalf("invalid port and error are not visible together:\n%s", view)
}
}
func TestCtrlQQuitsCleanStateAndConfirmsDirtyForm(t *testing.T) {
t.Run("clean manager", func(t *testing.T) {
m := New(nil)
m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(1, "prod", 80, 24)
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
if cmd == nil {
t.Fatal("Ctrl+Q did not quit clean manager")
}
if _, ok := cmd().(tea.QuitMsg); !ok {
t.Fatalf("Ctrl+Q command returned %T", cmd())
}
})
t.Run("dirty server form", func(t *testing.T) {
m := New(nil)
m.screen = screenForm
m.form = newFormModel(80, 24)
m.form.inputs[0].SetValue("prod")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.focus != confirmCancel {
t.Fatalf("dirty Ctrl+Q did not open safe discard: screen=%v confirm=%#v", m.screen, m.confirm)
}
m.confirm.focus = confirmAccept
updated, cmd = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
t.Fatal("accepted quit confirmation produced no command")
}
updated, quitCmd := m.Update(cmd())
m = updated.(*tuiModel)
if quitCmd == nil || m.confirm != nil || m.form != nil {
t.Fatalf("accepted dirty quit did not clear state: confirm=%v form=%v", m.confirm, m.form)
}
if _, ok := quitCmd().(tea.QuitMsg); !ok {
t.Fatalf("accepted dirty quit returned %T", quitCmd())
}
})
}
func TestDirtyServerFormRequiresDiscardConfirmation(t *testing.T) {
oldList := ListServers
t.Cleanup(func() { ListServers = oldList })

View File

@ -242,10 +242,17 @@ func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardF
fm.descInput.SetValue(fwd.Description)
fm.currentType = fwd.Type
fm.typeIdx = typeIndex(fwd.Type)
if fwd.Type == model.ForwardRemote {
fm.inputs[0].SetValue(fwd.RemoteAddr)
fm.inputs[1].SetValue(strconv.Itoa(fwd.RemotePort))
fm.inputs[2].SetValue(fwd.LocalAddr)
fm.inputs[3].SetValue(strconv.Itoa(fwd.LocalPort))
} else {
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))
}
fm.updateFocus()
fm.initial = fm.snapshot()
return fm
@ -328,7 +335,7 @@ 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
fm.applySaveError(msg.err)
return fm, nil
}
@ -462,24 +469,24 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg {
name := strings.TrimSpace(fm.nameInput.Value())
desc := strings.TrimSpace(fm.descInput.Value())
localPort, err := parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
remotePort := 0
localAddr := strings.TrimSpace(fm.inputs[0].Value())
remoteAddr := strings.TrimSpace(fm.inputs[2].Value())
localAddr, remoteAddr := "", ""
localPort, remotePort := 0, 0
var err error
if name == "" {
return saveDoneMsg{err: fmt.Errorf("name is required")}
}
switch fm.currentType {
case model.ForwardLocal:
localAddr = strings.TrimSpace(fm.inputs[0].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
remoteAddr = strings.TrimSpace(fm.inputs[2].Value())
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")}
}
@ -488,20 +495,31 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
return saveDoneMsg{err: err}
}
case model.ForwardRemote:
remoteAddr = strings.TrimSpace(fm.inputs[0].Value())
if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")}
}
remotePort, err = parseNamedPort("Remote port", fm.inputs[3].Value())
remotePort, err = parseNamedPort("Remote listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
localAddr = strings.TrimSpace(fm.inputs[2].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Local target port", fm.inputs[3].Value())
if err != nil {
return saveDoneMsg{err: err}
}
case model.ForwardDynamic:
localAddr = strings.TrimSpace(fm.inputs[0].Value())
if localAddr == "" {
localAddr = "127.0.0.1"
}
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
remoteAddr = ""
remotePort = 0
}
@ -534,6 +552,33 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
}
}
func (fm *forwardFormModel) applySaveError(err error) {
fm.err = err
if err == nil {
return
}
message := strings.ToLower(err.Error())
fieldIndex := -1
switch {
case strings.Contains(message, "name is required"):
fm.focusIdx = 0
fm.updateFocus()
return
case strings.Contains(message, "listen address"):
fieldIndex = 0
case strings.Contains(message, "listen port"):
fieldIndex = 1
case strings.Contains(message, "target host"):
fieldIndex = 2
case strings.Contains(message, "target port"):
fieldIndex = 3
}
if fieldIndex >= 0 {
fm.focusIdx = 2 + len(forwardTypes) + fieldIndex
fm.updateFocus()
}
}
func parseNamedPort(label, value string) (int, error) {
port, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {

View File

@ -2,6 +2,7 @@ package tui
import (
"fmt"
"reflect"
"testing"
tea "github.com/charmbracelet/bubbletea"
@ -26,6 +27,51 @@ func TestForwardFormDigitsReachFocusedInput(t *testing.T) {
}
}
func TestRemoteForwardFormMapsListenAndTargetEndpoints(t *testing.T) {
oldSave := SaveForward
t.Cleanup(func() { SaveForward = oldSave })
var saved *model.Forward
SaveForward = func(forward *model.Forward) error {
copy := *forward
saved = &copy
return nil
}
fm := newForwardFormModel(7, 80, 24)
fm.currentType = model.ForwardRemote
fm.typeIdx = typeIndex(model.ForwardRemote)
fm.nameInput.SetValue("remote web")
fm.inputs[0].SetValue("0.0.0.0")
fm.inputs[1].SetValue("18080")
fm.inputs[2].SetValue("127.0.0.1")
fm.inputs[3].SetValue("8080")
msg := fm.runSave()()
if result, ok := msg.(saveDoneMsg); !ok || result.err != nil {
t.Fatalf("save result = %#v", msg)
}
if saved == nil {
t.Fatal("forward was not saved")
}
if saved.RemoteAddr != "0.0.0.0" || saved.RemotePort != 18080 || saved.LocalAddr != "127.0.0.1" || saved.LocalPort != 8080 {
t.Fatalf("remote forward endpoints were reversed: %#v", saved)
}
wantArgs := []string{"-R", "0.0.0.0:18080:127.0.0.1:8080"}
if got := saved.ForwardSSHArgs(); !reflect.DeepEqual(got, wantArgs) {
t.Fatalf("ForwardSSHArgs() = %#v, want %#v", got, wantArgs)
}
}
func TestRemoteForwardEditPopulatesSemanticFields(t *testing.T) {
forward := &model.Forward{ID: 9, Type: model.ForwardRemote, RemoteAddr: "0.0.0.0", RemotePort: 18080, LocalAddr: "127.0.0.1", LocalPort: 8080}
fm := newForwardEditModel(7, forward, 80, 24)
want := []string{"0.0.0.0", "18080", "127.0.0.1", "8080"}
for index, value := range want {
if got := fm.inputs[index].Value(); got != value {
t.Fatalf("input[%d] = %q, want %q", index, got, value)
}
}
}
func TestForwardFormDigitShortcutsWorkOnTypeSelector(t *testing.T) {
tests := []struct {
digit rune

View File

@ -59,6 +59,13 @@ func fitLine(value string, width int) string {
return truncateCells(value, width)
}
func wrapCells(value string, width int) []string {
if width <= 0 {
return []string{""}
}
return strings.Split(ansi.Wrap(value, width, ""), "\n")
}
func minimumSizeView(width int) string {
message := "sshkeeper needs at least 60x16"
if width <= 0 {

View File

@ -141,6 +141,27 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
}
}
func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
m := New(nil)
m.width, m.height = size.width, size.height
m.beginConfirm(confirmState{
title: "Delete port forward?",
target: `"Очень длинный Local PostgreSQL 数据库 forward" · 127.0.0.1:15432 → database.internal.example:5432`,
consequence: "This removes the saved forwarding rule. Active tunnels are not stopped.",
verb: "Delete",
parent: screenForwardList,
})
view := m.View()
assertViewFits(t, view, size.width, size.height)
for _, want := range []string{"Local PostgreSQL", "not stopped.", "> [ Cancel ]", "Esc"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation at %dx%d missing %q:\n%s", size.width, size.height, want, view)
}
}
}
}
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newTemplateFormModel(nil, size.width, size.height)