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 7bcd6e6..53a3184 100644 Binary files a/docs/screenshots/screen_5.png and b/docs/screenshots/screen_5.png differ diff --git a/docs/superpowers/plans/2026-08-14-tui-ux-redesign.md b/docs/superpowers/plans/2026-08-14-tui-ux-redesign.md index 631ca60..7c3e409 100644 --- a/docs/superpowers/plans/2026-08-14-tui-ux-redesign.md +++ b/docs/superpowers/plans/2026-08-14-tui-ux-redesign.md @@ -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. - diff --git a/docs/superpowers/specs/2026-08-14-tui-ux-redesign-design.md b/docs/superpowers/specs/2026-08-14-tui-ux-redesign-design.md index cac975a..022f90d 100644 --- a/docs/superpowers/specs/2026-08-14-tui-ux-redesign-design.md +++ b/docs/superpowers/specs/2026-08-14-tui-ux-redesign-design.md @@ -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. - diff --git a/internal/tui/app.go b/internal/tui/app.go index c559aef..7eb02cb 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -61,13 +61,17 @@ type saveDoneMsg struct { } type templatesLoadedMsg struct { - templates []*model.CommandTemplate - err error + templates []*model.CommandTemplate + deleted bool + deletedName string + err error } type tagsLoadedMsg struct { - tags []string - err error + tags []string + deleted bool + deletedName string + err error } type backgroundRunDoneMsg struct { @@ -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 { - m.err = msg.err + 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 { - m.err = msg.err + 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 { - 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)