fix: make tui destructive actions safe

This commit is contained in:
mirivlad 2026-08-14 03:16:50 +08:00
parent 5391612a14
commit 84a61d4895
4 changed files with 437 additions and 87 deletions

View File

@ -94,6 +94,12 @@ type forwardDeletedMsg struct {
err error err error
} }
type serverDeletedMsg struct {
alias string
servers []*model.Server
err error
}
type importDoneMsg struct { type importDoneMsg struct {
servers []*model.Server servers []*model.Server
count int count int
@ -205,6 +211,24 @@ const (
screenFullHelp screenFullHelp
) )
type confirmChoice int
const (
confirmCancel confirmChoice = iota
confirmAccept
)
type confirmState struct {
title string
target string
consequence string
verb string
parent screen
focus confirmChoice
pending bool
action func() tea.Cmd
}
// --- Result type — returned from TUI to caller --- // --- Result type — returned from TUI to caller ---
type TUIResult struct { type TUIResult struct {
@ -244,8 +268,7 @@ type tuiModel struct {
actionMenu *actionMenuModel actionMenu *actionMenuModel
forwardScreen *forwardScreenModel forwardScreen *forwardScreenModel
forwardForm *forwardFormModel forwardForm *forwardFormModel
confirmMsg string confirm *confirmState
confirmAction func() tea.Cmd
fullHelp *fullHelpModel fullHelp *fullHelpModel
} }
@ -323,6 +346,9 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case templatesLoadedMsg: case templatesLoadedMsg:
if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTemplates {
m.finishConfirm()
}
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
return m, nil return m, nil
@ -331,6 +357,9 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tagsLoadedMsg: case tagsLoadedMsg:
if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTags {
m.finishConfirm()
}
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
return m, nil return m, nil
@ -391,20 +420,48 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case forwardDeletedMsg: case forwardDeletedMsg:
if m.forwardScreen != nil && msg.err == nil { m.finishConfirm()
if m.forwardScreen != nil {
if msg.err != nil {
m.forwardScreen.err = msg.err
return m, nil
}
m.forwardScreen.err = nil
return m, m.forwardScreen.loadForwards() return m, m.forwardScreen.loadForwards()
} }
return m, nil return m, nil
case forwardDeleteConfirmMsg: case serverDeletedMsg:
// Show confirmation screen m.finishConfirm()
m.confirmMsg = fmt.Sprintf("Delete forward %q?", msg.name) if msg.err != nil {
m.confirmAction = func() tea.Cmd { m.err = msg.err
return func() tea.Msg { return m, nil
return forwardDeletedMsg{id: msg.id, err: DeleteForward(msg.id)}
}
} }
m.screen = screenConfirm m.servers = msg.servers
items := make([]list.Item, len(msg.servers))
for i, server := range msg.servers {
items[i] = serverItem{server: server}
}
m.list.SetItems(items)
delete(m.selected, msg.alias)
return m, nil
case forwardDeleteConfirmMsg:
m.beginConfirm(confirmState{
title: "Delete port forward?",
target: msg.name,
consequence: "This removes the saved forwarding rule.",
verb: "Delete",
parent: screenForwardList,
action: func() tea.Cmd {
return func() tea.Msg {
if DeleteForward == nil {
return forwardDeletedMsg{id: msg.id, err: fmt.Errorf("forward deletion is unavailable")}
}
return forwardDeletedMsg{id: msg.id, err: DeleteForward(msg.id)}
}
},
})
return m, nil return m, nil
case forwardEditSignal: case forwardEditSignal:
@ -430,7 +487,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tunnelStoppedMsg: case tunnelStoppedMsg:
if m.tunnelScreen != nil && msg.err == nil { m.finishConfirm()
if m.tunnelScreen != nil {
if msg.err != nil {
m.tunnelScreen.err = msg.err
return m, nil
}
m.tunnelScreen.err = nil
return m, m.tunnelScreen.loadTunnels() return m, m.tunnelScreen.loadTunnels()
} }
return m, nil return m, nil
@ -594,14 +657,8 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyCtrlD: case tea.KeyCtrlD:
if item, ok := m.list.SelectedItem().(serverItem); ok { if item, ok := m.list.SelectedItem().(serverItem); ok {
return m, func() tea.Msg { m.confirmServerDelete(item.server)
err := DeleteServer(item.server.Alias) return m, nil
if err != nil {
return saveDoneMsg{err: err}
}
servers, err := ListServers()
return serversLoadedMsg{servers: servers, err: err}
}
} }
case tea.KeyCtrlT: case tea.KeyCtrlT:
@ -714,15 +771,31 @@ func (m *tuiModel) updateTags(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case tea.KeyCtrlD: case tea.KeyCtrlD:
if item, ok := m.tagList.SelectedItem().(groupItem); ok && DeleteTag != nil { if item, ok := m.tagList.SelectedItem().(groupItem); ok {
name := item.name name := item.name
return m, func() tea.Msg { m.beginConfirm(confirmState{
if err := DeleteTag(name); err != nil { title: "Delete tag?",
return tagsLoadedMsg{err: err} target: fmt.Sprintf("%q", name),
} consequence: "This removes the tag from every server profile.",
tags, err := ListTags() verb: "Delete",
return tagsLoadedMsg{tags: tags, err: err} parent: screenTags,
} action: func() tea.Cmd {
return func() tea.Msg {
if DeleteTag == nil {
return tagsLoadedMsg{err: fmt.Errorf("tag deletion is unavailable")}
}
if err := DeleteTag(name); err != nil {
return tagsLoadedMsg{err: err}
}
if ListTags == nil {
return tagsLoadedMsg{err: fmt.Errorf("tag reload is unavailable")}
}
tags, err := ListTags()
return tagsLoadedMsg{tags: tags, err: err}
}
},
})
return m, nil
} }
case tea.KeyEnter: case tea.KeyEnter:
if item, ok := m.tagList.SelectedItem().(groupItem); ok && SetServerTags != nil { if item, ok := m.tagList.SelectedItem().(groupItem); ok && SetServerTags != nil {
@ -807,15 +880,31 @@ func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case tea.KeyCtrlD: case tea.KeyCtrlD:
if item, ok := m.templateList.SelectedItem().(templateItem); ok && DeleteCommandTemplate != nil { if item, ok := m.templateList.SelectedItem().(templateItem); ok {
name := item.template.Name name := item.template.Name
return m, func() tea.Msg { m.beginConfirm(confirmState{
if err := DeleteCommandTemplate(name); err != nil { title: "Delete command template?",
return templatesLoadedMsg{err: err} target: fmt.Sprintf("%q", name),
} consequence: "This removes the saved command template.",
templates, err := ListCommandTemplates() verb: "Delete",
return templatesLoadedMsg{templates: templates, err: err} parent: screenTemplates,
} action: func() tea.Cmd {
return func() tea.Msg {
if DeleteCommandTemplate == nil {
return templatesLoadedMsg{err: fmt.Errorf("template deletion is unavailable")}
}
if err := DeleteCommandTemplate(name); err != nil {
return templatesLoadedMsg{err: err}
}
if ListCommandTemplates == nil {
return templatesLoadedMsg{err: fmt.Errorf("template reload is unavailable")}
}
templates, err := ListCommandTemplates()
return templatesLoadedMsg{templates: templates, err: err}
}
},
})
return m, nil
} }
} }
var cmd tea.Cmd var cmd tea.Cmd
@ -1141,16 +1230,9 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case "delete": case "delete":
if item, ok := m.list.SelectedItem().(serverItem); ok { if item, ok := m.list.SelectedItem().(serverItem); ok {
m.screen = screenList
m.actionMenu = nil m.actionMenu = nil
return m, func() tea.Msg { m.confirmServerDelete(item.server)
err := DeleteServer(item.server.Alias) return m, nil
if err != nil {
return saveDoneMsg{err: err}
}
servers, err := ListServers()
return serversLoadedMsg{servers: servers, err: err}
}
} }
case "import": case "import":
m.screen = screenList m.screen = screenList
@ -1207,13 +1289,7 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyCtrlD: case tea.KeyCtrlD:
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
fwd := m.forwardScreen.list[m.forwardScreen.selected] fwd := m.forwardScreen.list[m.forwardScreen.selected]
m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name) m.confirmForwardDelete(fwd)
m.confirmAction = func() tea.Cmd {
return func() tea.Msg {
return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)}
}
}
m.screen = screenConfirm
return m, nil return m, nil
} }
case tea.KeyCtrlE, tea.KeyEnter: case tea.KeyCtrlE, tea.KeyEnter:
@ -1231,13 +1307,7 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "d", "D": case "d", "D":
if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) {
fwd := m.forwardScreen.list[m.forwardScreen.selected] fwd := m.forwardScreen.list[m.forwardScreen.selected]
m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name) m.confirmForwardDelete(fwd)
m.confirmAction = func() tea.Cmd {
return func() tea.Msg {
return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)}
}
}
m.screen = screenConfirm
return m, nil return m, nil
} }
} }
@ -1262,9 +1332,8 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tunnelScreen = nil m.tunnelScreen = nil
return m, nil return m, nil
case tea.KeyCtrlD: case tea.KeyCtrlD:
if m.tunnelScreen != nil { m.confirmTunnelStop()
return m, m.tunnelScreen.stopSelected() return m, nil
}
case tea.KeyCtrlR: case tea.KeyCtrlR:
if m.tunnelScreen != nil { if m.tunnelScreen != nil {
return m, m.tunnelScreen.loadTunnels() return m, m.tunnelScreen.loadTunnels()
@ -1272,9 +1341,8 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyRunes: case tea.KeyRunes:
switch msg.String() { switch msg.String() {
case "d", "D", "s", "S": case "d", "D", "s", "S":
if m.tunnelScreen != nil { m.confirmTunnelStop()
return m, m.tunnelScreen.stopSelected() return m, nil
}
case "r", "R": case "r", "R":
if m.tunnelScreen != nil { if m.tunnelScreen != nil {
return m, m.tunnelScreen.loadTunnels() return m, m.tunnelScreen.loadTunnels()
@ -1287,51 +1355,170 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
func (m *tuiModel) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *tuiModel) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.confirm == nil {
m.screen = screenList
return m, nil
}
if m.confirm.pending {
return m, nil
}
switch msg.Type { switch msg.Type {
case tea.KeyEsc: case tea.KeyEsc:
m.screen = screenList return m.cancelConfirm()
m.confirmMsg = "" case tea.KeyTab, tea.KeyShiftTab, tea.KeyLeft, tea.KeyRight:
m.confirmAction = nil if m.confirm.focus == confirmCancel {
m.confirm.focus = confirmAccept
} else {
m.confirm.focus = confirmCancel
}
return m, nil return m, nil
case tea.KeyEnter: case tea.KeyEnter:
if m.confirmAction != nil { if m.confirm.focus == confirmCancel {
action := m.confirmAction return m.cancelConfirm()
m.confirmMsg = ""
m.confirmAction = nil
return m, action()
} }
return m.acceptConfirm()
case tea.KeyRunes: case tea.KeyRunes:
switch msg.String() { switch msg.String() {
case "y", "Y": case "y", "Y":
if m.confirmAction != nil { return m.acceptConfirm()
action := m.confirmAction
m.confirmMsg = ""
m.confirmAction = nil
return m, action()
}
case "n", "N": case "n", "N":
m.screen = screenList return m.cancelConfirm()
m.confirmMsg = ""
m.confirmAction = nil
return m, nil
} }
} }
return m, nil return m, nil
} }
func (m *tuiModel) viewConfirm() string { func (m *tuiModel) viewConfirm() string {
if m.confirm == nil {
return ""
}
var b strings.Builder var b strings.Builder
b.WriteString(titleStyle.Render("Confirm")) b.WriteString(titleStyle.Render(m.confirm.title))
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(" " + m.confirmMsg) b.WriteString(" " + m.confirm.target)
if m.confirm.consequence != "" {
b.WriteString("\n\n " + m.confirm.consequence)
}
b.WriteString("\n\n") b.WriteString("\n\n")
cancel := "[ Cancel ]"
accept := "[ " + m.confirm.verb + " ]"
if m.confirm.focus == confirmCancel {
cancel = selectedStyle.Render("> " + cancel)
} else {
accept = errorStyle.Render("> " + accept)
}
if m.confirm.pending {
b.WriteString(" " + m.confirm.verb + " in progress…\n\n")
} else {
b.WriteString(" " + cancel + " " + accept + "\n\n")
}
b.WriteString(renderHelp([]helpItem{ b.WriteString(renderHelp([]helpItem{
{Key: "Enter / Y", Action: "yes"}, {Key: "Tab", Action: "choose"},
{Key: "Esc / N", Action: "no"}, {Key: "Enter", Action: "activate"},
{Key: "Esc", Action: "cancel"},
}, m.width)) }, m.width))
return b.String() return b.String()
} }
func (m *tuiModel) beginConfirm(state confirmState) {
state.focus = confirmCancel
m.confirm = &state
m.screen = screenConfirm
}
func (m *tuiModel) cancelConfirm() (tea.Model, tea.Cmd) {
parent := m.confirm.parent
m.confirm = nil
m.screen = parent
return m, nil
}
func (m *tuiModel) acceptConfirm() (tea.Model, tea.Cmd) {
if m.confirm == nil || m.confirm.pending || m.confirm.action == nil {
return m, nil
}
m.confirm.pending = true
return m, m.confirm.action()
}
func (m *tuiModel) finishConfirm() {
if m.confirm == nil {
return
}
m.screen = m.confirm.parent
m.confirm = nil
}
func (m *tuiModel) confirmServerDelete(server *model.Server) {
alias := server.Alias
m.beginConfirm(confirmState{
title: "Delete server profile?",
target: fmt.Sprintf("%q", alias),
consequence: "This also removes its saved port forwards and vault secrets.",
verb: "Delete",
parent: screenList,
action: func() tea.Cmd {
return func() tea.Msg {
if DeleteServer == nil {
return serverDeletedMsg{alias: alias, err: fmt.Errorf("server deletion is unavailable")}
}
if err := DeleteServer(alias); err != nil {
return serverDeletedMsg{alias: alias, err: err}
}
if ListServers == nil {
return serverDeletedMsg{alias: alias, err: fmt.Errorf("server reload is unavailable")}
}
servers, err := ListServers()
return serverDeletedMsg{alias: alias, servers: servers, err: err}
}
},
})
}
func (m *tuiModel) confirmForwardDelete(fwd *model.Forward) {
name := fwd.Name
if strings.TrimSpace(name) == "" {
name = fwd.ForwardListen()
}
id := fwd.ID
m.beginConfirm(confirmState{
title: "Delete port forward?",
target: fmt.Sprintf("%q · %s → %s", name, fwd.ForwardListen(), fwd.ForwardTarget()),
consequence: "This removes the saved forwarding rule. Active tunnels are not stopped.",
verb: "Delete",
parent: screenForwardList,
action: func() tea.Cmd {
return func() tea.Msg {
if DeleteForward == nil {
return forwardDeletedMsg{id: id, err: fmt.Errorf("forward deletion is unavailable")}
}
return forwardDeletedMsg{id: id, err: DeleteForward(id)}
}
},
})
}
func (m *tuiModel) confirmTunnelStop() {
if m.tunnelScreen == nil {
return
}
item, ok := m.tunnelScreen.list.SelectedItem().(tunnelItem)
if !ok || item.state == nil {
return
}
state := item.state
m.beginConfirm(confirmState{
title: "Stop running tunnel?",
target: fmt.Sprintf("%q · PID %d · %s", state.Name, state.PID, state.ServerAlias),
consequence: "Active forwarded connections through this process will close.",
verb: "Stop",
parent: screenTunnelManager,
action: func() tea.Cmd {
return m.tunnelScreen.stopSelected()
},
})
}
func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
updated, _ := m.fullHelp.Update(msg) updated, _ := m.fullHelp.Update(msg)
if fh, ok := updated.(*fullHelpModel); ok { if fh, ok := updated.(*fullHelpModel); ok {

View File

@ -0,0 +1,155 @@
package tui
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/mirivlad/sshkeeper/internal/model"
)
func TestServerDeleteRequiresExplicitConfirmation(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22, User: "root", AuthMethod: model.AuthKey}
m := New([]*model.Server{server})
deleted := 0
oldDelete, oldList := DeleteServer, ListServers
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
DeleteServer = func(alias string) error {
deleted++
return nil
}
ListServers = func() ([]*model.Server, error) { return nil, nil }
updated, cmd := m.updateList(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if cmd != nil || deleted != 0 {
t.Fatalf("delete ran before confirmation: cmd=%v deleted=%d", cmd != nil, deleted)
}
if m.screen != screenConfirm || m.confirm == nil {
t.Fatalf("expected confirmation screen, got screen=%v confirm=%v", m.screen, m.confirm)
}
if m.confirm.focus != confirmCancel {
t.Fatalf("default focus = %v, want Cancel", m.confirm.focus)
}
view := m.View()
for _, want := range []string{"prod", "saved port forwards", "vault secrets", "> [ Cancel ]", "[ Delete ]"} {
if !strings.Contains(view, want) {
t.Fatalf("confirmation missing %q:\n%s", want, view)
}
}
updated, cmd = m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd != nil || deleted != 0 || m.screen != screenList {
t.Fatalf("Enter on default Cancel must cancel: screen=%v cmd=%v deleted=%d", m.screen, cmd != nil, deleted)
}
}
func TestServerDeleteRunsOnceAndReturnsToList(t *testing.T) {
server := &model.Server{ID: 1, Alias: "prod", Host: "prod.example", Port: 22, User: "root", AuthMethod: model.AuthKey}
m := New([]*model.Server{server})
deleted := 0
oldDelete, oldList := DeleteServer, ListServers
t.Cleanup(func() { DeleteServer, ListServers = oldDelete, oldList })
DeleteServer = func(alias string) error {
deleted++
return nil
}
ListServers = func() ([]*model.Server, error) { return nil, nil }
updated, _ := m.updateList(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(*tuiModel)
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil || m.confirm == nil || !m.confirm.pending {
t.Fatal("expected pending destructive command")
}
_, duplicate := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
if duplicate != nil {
t.Fatal("repeated Enter must not start a duplicate delete")
}
msg := cmd()
updated, _ = m.Update(msg)
m = updated.(*tuiModel)
if deleted != 1 {
t.Fatalf("delete calls = %d, want 1", deleted)
}
if m.screen != screenList || m.confirm != nil || len(m.servers) != 0 {
t.Fatalf("unexpected completion state: screen=%v confirm=%v servers=%d", m.screen, m.confirm, len(m.servers))
}
}
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}
m := New([]*model.Server{server})
m.screen = screenForwardList
m.forwardScreen = newForwardScreenModel(server.ID, server.Alias, 80, 24)
m.forwardScreen.list = []*model.Forward{fwd}
m.forwardScreen.selected = 0
oldDelete := DeleteForward
t.Cleanup(func() { DeleteForward = oldDelete })
DeleteForward = func(id int64) error { return errors.New("database is read-only") }
updated, _ := m.updateForwardList(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if m.confirm == nil || m.confirm.parent != screenForwardList {
t.Fatalf("expected forward-list parent, confirm=%#v", m.confirm)
}
updated, _ = m.updateConfirm(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(*tuiModel)
updated, cmd := m.updateConfirm(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
t.Fatal("expected delete command")
}
updated, _ = m.Update(cmd())
m = updated.(*tuiModel)
if m.screen != screenForwardList || m.confirm != nil {
t.Fatalf("expected return to forwards, screen=%v confirm=%v", m.screen, m.confirm)
}
if m.forwardScreen == nil || m.forwardScreen.err == nil || !strings.Contains(m.forwardScreen.View(), "database is read-only") {
t.Fatalf("forward error not retained: model=%#v view=%q", m.forwardScreen, m.forwardScreen.View())
}
}
func TestOtherDestructiveActionsOpenConfirmation(t *testing.T) {
t.Run("tag", func(t *testing.T) {
m := New(nil)
m.screen = screenTags
m.setTags([]string{"prod"})
updated, cmd := m.updateTags(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTags {
t.Fatalf("tag deletion did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
}
})
t.Run("template", func(t *testing.T) {
m := New(nil)
m.screen = screenTemplates
m.setTemplates([]*model.CommandTemplate{{Name: "uptime", Command: "uptime"}})
updated, cmd := m.updateTemplates(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTemplates {
t.Fatalf("template deletion did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
}
})
t.Run("tunnel", func(t *testing.T) {
m := New(nil)
m.screen = screenTunnelManager
m.tunnelScreen = newTunnelScreenModel(80, 24)
m.tunnelScreen.tunnels = []*model.TunnelState{{ID: 11, Name: "prod tunnel", ServerAlias: "prod"}}
m.tunnelScreen.rebuildList()
updated, cmd := m.updateTunnelManager(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(*tuiModel)
if cmd != nil || m.screen != screenConfirm || m.confirm == nil || m.confirm.parent != screenTunnelManager {
t.Fatalf("tunnel stop did not open confirmation: screen=%v confirm=%#v cmd=%v", m.screen, m.confirm, cmd != nil)
}
})
}

View File

@ -131,6 +131,10 @@ func (m *forwardScreenModel) View() string {
} }
b.WriteString("\n") b.WriteString("\n")
if m.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
b.WriteString("\n\n")
}
b.WriteString(renderHelp([]helpItem{ b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+A (a)", Action: "add"}, {Key: "Ctrl+A (a)", Action: "add"},
{Key: "Ctrl+E/Enter", Action: "edit"}, {Key: "Ctrl+E/Enter", Action: "edit"},

View File

@ -97,6 +97,10 @@ func (m *tunnelScreenModel) View() string {
var b strings.Builder var b strings.Builder
b.WriteString(m.list.View()) b.WriteString(m.list.View())
b.WriteString("\n\n") b.WriteString("\n\n")
if m.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
b.WriteString("\n\n")
}
b.WriteString(renderHelp([]helpItem{ b.WriteString(renderHelp([]helpItem{
{Key: "Ctrl+D (s)", Action: "stop tunnel"}, {Key: "Ctrl+D (s)", Action: "stop tunnel"},
{Key: "Ctrl+R (r)", Action: "refresh"}, {Key: "Ctrl+R (r)", Action: "refresh"},