From be800de90b8609ab0bf3d85a8ad46dafc37624ac Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 14 Aug 2026 04:04:10 +0800 Subject: [PATCH] fix: address tui redesign review findings --- README.md | 20 +- docs/guide.md | 3 + docs/screenshots/screen_5.png | Bin 25343 -> 25338 bytes .../plans/2026-08-14-tui-ux-redesign.md | 1 - .../2026-08-14-tui-ux-redesign-design.md | 1 - internal/tui/app.go | 175 +++++++++++++++--- internal/tui/confirm_test.go | 66 +++++++ internal/tui/form.go | 21 ++- internal/tui/form_validation_test.go | 80 ++++++++ internal/tui/forward.go | 75 ++++++-- internal/tui/forward_test.go | 46 +++++ internal/tui/layout.go | 7 + internal/tui/layout_test.go | 21 +++ 13 files changed, 461 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 3d30541..b27169b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/guide.md b/docs/guide.md index 3011fbd..96d6cf1 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -173,6 +173,9 @@ Auth: agent Group: - Status: ? | `F1` | Полная справка по приложению | | `Ctrl+Q` | Выход | +`Ctrl+Q` работает глобально. Если активная форма содержит несохранённые +изменения, сначала открывается безопасное подтверждение с выбранным Cancel. + ### Быстрая справка по клавишам Нажмите `?` на экране списка или менеджера. В текстовом поле символ `?` diff --git a/docs/screenshots/screen_5.png b/docs/screenshots/screen_5.png index 7bcd6e6050fcc763de65c359d443972811e5c015..53a318498faa1dd37e1ceaf10e530f34fae30fb4 100644 GIT binary patch literal 25338 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 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 { - m.err = msg.err + 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 { diff --git a/internal/tui/confirm_test.go b/internal/tui/confirm_test.go index 48426dd..a165c13 100644 --- a/internal/tui/confirm_test.go +++ b/internal/tui/confirm_test.go @@ -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} diff --git a/internal/tui/form.go b/internal/tui/form.go index 13b08c5..27587dd 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -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() diff --git a/internal/tui/form_validation_test.go b/internal/tui/form_validation_test.go index 94fdd56..7a5b877 100644 --- a/internal/tui/form_validation_test.go +++ b/internal/tui/form_validation_test.go @@ -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 }) diff --git a/internal/tui/forward.go b/internal/tui/forward.go index 49ea7e2..7fd2f0f 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -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) - 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)) + 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 { diff --git a/internal/tui/forward_test.go b/internal/tui/forward_test.go index 3e92957..556e6d0 100644 --- a/internal/tui/forward_test.go +++ b/internal/tui/forward_test.go @@ -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 = © + 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 diff --git a/internal/tui/layout.go b/internal/tui/layout.go index 6c73b3a..9501b5a 100644 --- a/internal/tui/layout.go +++ b/internal/tui/layout.go @@ -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 { diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index ad63072..56e0e3c 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -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)