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 ### Main Window
``` ```
sshkeeper 0 servers sshkeeper / Servers Vault unlocked · 1 profiles
Vault unlocked | 0 OK | 0 FAIL ────────────────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────────────────────────────────────────────┐
NAME ALIAS ROUTE AUTH GROUP STATUS │1 servers │
│ NAME AUTH GROUP STATUS │
No servers yet. Press Ctrl+A to add one. │> Production agent - ? │
└──────────────────────────────────────────────────────────────────────────────┘
Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit Enter: connect | Ctrl+X: actions | Ctrl+A: add | Ctrl+E: edit | Ctrl+Q: quit
Ctrl+F: search | Ins: select | ?: hotkeys | F1: help | Ctrl+Q: quit
``` ```
### Quick Help (?) ### 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) ### Full Help (F1)

View File

@ -173,6 +173,9 @@ Auth: agent Group: - Status: ?
| `F1` | Полная справка по приложению | | `F1` | Полная справка по приложению |
| `Ctrl+Q` | Выход | | `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. - [ ] 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. - [ ] 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. - [ ] 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 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 120x40, 80x24, and 60x16. Each size is captured and visually inspected for
clipping, missing focus, missing actions, and false status. clipping, missing focus, missing actions, and false status.

View File

@ -62,11 +62,15 @@ type saveDoneMsg struct {
type templatesLoadedMsg struct { type templatesLoadedMsg struct {
templates []*model.CommandTemplate templates []*model.CommandTemplate
deleted bool
deletedName string
err error err error
} }
type tagsLoadedMsg struct { type tagsLoadedMsg struct {
tags []string tags []string
deleted bool
deletedName string
err error err error
} }
@ -97,9 +101,12 @@ type forwardDeletedMsg struct {
type serverDeletedMsg struct { type serverDeletedMsg struct {
alias string alias string
servers []*model.Server servers []*model.Server
deleted bool
err error err error
} }
type quitAfterDiscardMsg struct{}
type discardFormMsg struct { type discardFormMsg struct {
origin screen origin screen
} }
@ -397,7 +404,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.finishConfirm() m.finishConfirm()
} }
if msg.err != nil { 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 m.err = msg.err
}
return m, nil return m, nil
} }
m.setTemplates(msg.templates) m.setTemplates(msg.templates)
@ -408,7 +421,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.finishConfirm() m.finishConfirm()
} }
if msg.err != nil { 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 m.err = msg.err
}
return m, nil return m, nil
} }
m.setTags(msg.tags) m.setTags(msg.tags)
@ -474,14 +493,32 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.forwardScreen.err = 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, m.forwardScreen.loadForwards()
} }
return m, nil return m, nil
case serverDeletedMsg: case serverDeletedMsg:
m.finishConfirm() m.finishConfirm()
if msg.deleted {
m.removeServer(msg.alias)
}
if msg.err != nil { 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 m.err = msg.err
}
return m, nil return m, nil
} }
m.servers = msg.servers m.servers = msg.servers
@ -493,6 +530,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
delete(m.selected, msg.alias) delete(m.selected, msg.alias)
return m, nil return m, nil
case quitAfterDiscardMsg:
m.confirm = nil
m.form = nil
m.forwardForm = nil
m.templateForm = nil
return m, tea.Quit
case discardFormMsg: case discardFormMsg:
m.finishConfirm() m.finishConfirm()
switch msg.origin { switch msg.origin {
@ -588,7 +632,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case saveDoneMsg: case saveDoneMsg:
if m.forwardForm != nil { if m.forwardForm != nil {
if msg.err != nil { if msg.err != nil {
m.forwardForm.err = msg.err m.forwardForm.applySaveError(msg.err)
m.forwardForm.saved = false m.forwardForm.saved = false
// Stay on screenForwardForm to show error // Stay on screenForwardForm to show error
return m, nil return m, nil
@ -619,12 +663,14 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.form != nil { if m.form != nil {
m.form.saving = false m.form.saving = false
if msg.err != nil { if msg.err != nil {
m.form.err = msg.err m.form.applySaveError(msg.err)
m.form.saved = false m.form.saved = false
} else { } else {
m.form.saved = true m.form.saved = true
m.form.savedTime = time.Now() m.form.savedTime = time.Now()
m.form.err = nil m.form.err = nil
m.form.password.SetValue("")
m.form.initial = m.form.snapshot()
} }
} }
return m, nil return m, nil
@ -640,6 +686,9 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.screen = screenFullHelp m.screen = screenFullHelp
return m, nil 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 { if msg.Type == tea.KeyRunes && msg.String() == "?" && !m.screenOwnsPrintableInput() && m.screen != screenHelp && m.screen != screenFullHelp && m.screen != screenConfirm {
m.helpParent = m.screen m.helpParent = m.screen
m.helpScreen = newHelpScreenModel(m.width, m.height) 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 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) { func (m *tuiModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type { switch msg.Type {
case tea.KeyEsc: case tea.KeyEsc:
@ -859,10 +935,10 @@ func (m *tuiModel) updateTags(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return tagsLoadedMsg{err: err} return tagsLoadedMsg{err: err}
} }
if ListTags == nil { 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() 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} return templatesLoadedMsg{err: err}
} }
if ListCommandTemplates == nil { 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() 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 { if m.confirm == nil {
return "" return ""
} }
var b strings.Builder width := m.width
b.WriteString(titleStyle.Render(m.confirm.title)) if width <= 0 {
b.WriteString("\n\n") width = 80
b.WriteString(" " + m.confirm.target)
if m.confirm.consequence != "" {
b.WriteString("\n\n " + m.confirm.consequence)
} }
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 ]" cancel := "[ Cancel ]"
accept := "[ " + m.confirm.verb + " ]" accept := "[ " + m.confirm.verb + " ]"
if m.confirm.focus == confirmCancel { if m.confirm.focus == confirmCancel {
@ -1489,16 +1573,17 @@ func (m *tuiModel) viewConfirm() string {
accept = errorStyle.Render("> " + accept) accept = errorStyle.Render("> " + accept)
} }
if m.confirm.pending { if m.confirm.pending {
b.WriteString(" " + m.confirm.verb + " in progress…\n\n") lines = append(lines, fitLine(" "+m.confirm.verb+" in progress…", width), "")
} else { } 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: "Tab", Action: "choose"},
{Key: "Enter", Action: "activate"}, {Key: "Enter", Action: "activate"},
{Key: "Esc", Action: "cancel"}, {Key: "Esc", Action: "cancel"},
}, m.width)) }, width)
return b.String() lines = append(lines, strings.Split(footer, "\n")...)
return strings.Join(lines, "\n")
} }
func (m *tuiModel) beginConfirm(state confirmState) { func (m *tuiModel) beginConfirm(state confirmState) {
@ -1566,15 +1651,31 @@ func (m *tuiModel) confirmServerDelete(server *model.Server) {
return serverDeletedMsg{alias: alias, err: err} return serverDeletedMsg{alias: alias, err: err}
} }
if ListServers == nil { 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() 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) { func (m *tuiModel) confirmForwardDelete(fwd *model.Forward) {
name := fwd.Name name := fwd.Name
if strings.TrimSpace(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)) 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 --- // --- Server list footer ---
func (m *tuiModel) renderListHelp(selectedCount int, hasBackgroundResult bool) string { 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) { func TestForwardDeleteReturnsToForwardListAndRetainsError(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod"} 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} 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: case saveDoneMsg:
fm.saving = false fm.saving = false
if msg.err != nil { if msg.err != nil {
fm.err = msg.err fm.applySaveError(msg.err)
fm.saved = false fm.saved = false
} else { } else {
fm.saved = true fm.saved = true
@ -401,6 +401,25 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil 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() { func (fm *formModel) updateFocus() {
for i := range fm.inputs { for i := range fm.inputs {
fm.inputs[i].Blur() 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") { if fm.err == nil || !strings.Contains(fm.err.Error(), "Port") {
t.Fatalf("missing actionable port error: %v", fm.err) 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") { if view := fm.View(); !strings.Contains(view, "Port must be a number") {
t.Fatalf("validation error is not rendered:\n%s", view) 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) { func TestDirtyServerFormRequiresDiscardConfirmation(t *testing.T) {
oldList := ListServers oldList := ListServers
t.Cleanup(func() { ListServers = oldList }) 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.descInput.SetValue(fwd.Description)
fm.currentType = fwd.Type fm.currentType = fwd.Type
fm.typeIdx = typeIndex(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[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))
}
fm.updateFocus() fm.updateFocus()
fm.initial = fm.snapshot() fm.initial = fm.snapshot()
return fm return fm
@ -328,7 +335,7 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case saveDoneMsg: case saveDoneMsg:
fm.saved = (msg.err == nil) fm.saved = (msg.err == nil)
fm.err = msg.err fm.applySaveError(msg.err)
return fm, nil return fm, nil
} }
@ -462,24 +469,24 @@ func (fm *forwardFormModel) runSave() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
name := strings.TrimSpace(fm.nameInput.Value()) name := strings.TrimSpace(fm.nameInput.Value())
desc := strings.TrimSpace(fm.descInput.Value()) desc := strings.TrimSpace(fm.descInput.Value())
localAddr, remoteAddr := "", ""
localPort, err := parseNamedPort("Listen port", fm.inputs[1].Value()) localPort, remotePort := 0, 0
if err != nil { var err error
return saveDoneMsg{err: err}
}
remotePort := 0
localAddr := strings.TrimSpace(fm.inputs[0].Value())
remoteAddr := strings.TrimSpace(fm.inputs[2].Value())
if name == "" { if name == "" {
return saveDoneMsg{err: fmt.Errorf("name is required")} return saveDoneMsg{err: fmt.Errorf("name is required")}
} }
switch fm.currentType { switch fm.currentType {
case model.ForwardLocal: case model.ForwardLocal:
localAddr = strings.TrimSpace(fm.inputs[0].Value())
if localAddr == "" { if localAddr == "" {
localAddr = "127.0.0.1" 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 == "" { if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")} 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} return saveDoneMsg{err: err}
} }
case model.ForwardRemote: case model.ForwardRemote:
remoteAddr = strings.TrimSpace(fm.inputs[0].Value())
if remoteAddr == "" { if remoteAddr == "" {
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")} 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 { if err != nil {
return saveDoneMsg{err: err} return saveDoneMsg{err: err}
} }
localAddr = strings.TrimSpace(fm.inputs[2].Value())
if localAddr == "" { if localAddr == "" {
localAddr = "127.0.0.1" 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: case model.ForwardDynamic:
localAddr = strings.TrimSpace(fm.inputs[0].Value())
if localAddr == "" { if localAddr == "" {
localAddr = "127.0.0.1" localAddr = "127.0.0.1"
} }
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
if err != nil {
return saveDoneMsg{err: err}
}
remoteAddr = "" remoteAddr = ""
remotePort = 0 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) { func parseNamedPort(label, value string) (int, error) {
port, err := strconv.Atoi(strings.TrimSpace(value)) port, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil { if err != nil {

View File

@ -2,6 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"reflect"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea" 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) { func TestForwardFormDigitShortcutsWorkOnTypeSelector(t *testing.T) {
tests := []struct { tests := []struct {
digit rune digit rune

View File

@ -59,6 +59,13 @@ func fitLine(value string, width int) string {
return truncateCells(value, width) 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 { func minimumSizeView(width int) string {
message := "sshkeeper needs at least 60x16" message := "sshkeeper needs at least 60x16"
if width <= 0 { 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) { func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} { for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
form := newTemplateFormModel(nil, size.width, size.height) form := newTemplateFormModel(nil, size.width, size.height)