fix: address tui redesign review findings
This commit is contained in:
parent
dac7c7d37a
commit
be800de90b
20
README.md
20
README.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 = ©
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue