From 84a61d4895976015cada328ee878db241abdc6af Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 14 Aug 2026 03:16:50 +0800 Subject: [PATCH] fix: make tui destructive actions safe --- internal/tui/app.go | 361 ++++++++++++++++++++++++++--------- internal/tui/confirm_test.go | 155 +++++++++++++++ internal/tui/forward.go | 4 + internal/tui/tunnel.go | 4 + 4 files changed, 437 insertions(+), 87 deletions(-) create mode 100644 internal/tui/confirm_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 7ca6964..30c0eb8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -94,6 +94,12 @@ type forwardDeletedMsg struct { err error } +type serverDeletedMsg struct { + alias string + servers []*model.Server + err error +} + type importDoneMsg struct { servers []*model.Server count int @@ -205,6 +211,24 @@ const ( 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 --- type TUIResult struct { @@ -244,8 +268,7 @@ type tuiModel struct { actionMenu *actionMenuModel forwardScreen *forwardScreenModel forwardForm *forwardFormModel - confirmMsg string - confirmAction func() tea.Cmd + confirm *confirmState fullHelp *fullHelpModel } @@ -323,6 +346,9 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case templatesLoadedMsg: + if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTemplates { + m.finishConfirm() + } if msg.err != nil { m.err = msg.err return m, nil @@ -331,6 +357,9 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tagsLoadedMsg: + if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenTags { + m.finishConfirm() + } if msg.err != nil { m.err = msg.err return m, nil @@ -391,20 +420,48 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil 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, nil - case forwardDeleteConfirmMsg: - // Show confirmation screen - m.confirmMsg = fmt.Sprintf("Delete forward %q?", msg.name) - m.confirmAction = func() tea.Cmd { - return func() tea.Msg { - return forwardDeletedMsg{id: msg.id, err: DeleteForward(msg.id)} - } + case serverDeletedMsg: + m.finishConfirm() + if msg.err != nil { + m.err = msg.err + return m, nil } - 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 case forwardEditSignal: @@ -430,7 +487,13 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil 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, nil @@ -594,14 +657,8 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyCtrlD: if item, ok := m.list.SelectedItem().(serverItem); ok { - return m, func() tea.Msg { - err := DeleteServer(item.server.Alias) - if err != nil { - return saveDoneMsg{err: err} - } - servers, err := ListServers() - return serversLoadedMsg{servers: servers, err: err} - } + m.confirmServerDelete(item.server) + return m, nil } case tea.KeyCtrlT: @@ -714,15 +771,31 @@ func (m *tuiModel) updateTags(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case tea.KeyCtrlD: - if item, ok := m.tagList.SelectedItem().(groupItem); ok && DeleteTag != nil { + if item, ok := m.tagList.SelectedItem().(groupItem); ok { name := item.name - return m, func() tea.Msg { - if err := DeleteTag(name); err != nil { - return tagsLoadedMsg{err: err} - } - tags, err := ListTags() - return tagsLoadedMsg{tags: tags, err: err} - } + m.beginConfirm(confirmState{ + title: "Delete tag?", + target: fmt.Sprintf("%q", name), + consequence: "This removes the tag from every server profile.", + verb: "Delete", + 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: 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 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 - return m, func() tea.Msg { - if err := DeleteCommandTemplate(name); err != nil { - return templatesLoadedMsg{err: err} - } - templates, err := ListCommandTemplates() - return templatesLoadedMsg{templates: templates, err: err} - } + m.beginConfirm(confirmState{ + title: "Delete command template?", + target: fmt.Sprintf("%q", name), + consequence: "This removes the saved command template.", + verb: "Delete", + 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 @@ -1141,16 +1230,9 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "delete": if item, ok := m.list.SelectedItem().(serverItem); ok { - m.screen = screenList m.actionMenu = nil - return m, func() tea.Msg { - err := DeleteServer(item.server.Alias) - if err != nil { - return saveDoneMsg{err: err} - } - servers, err := ListServers() - return serversLoadedMsg{servers: servers, err: err} - } + m.confirmServerDelete(item.server) + return m, nil } case "import": m.screen = screenList @@ -1207,13 +1289,7 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyCtrlD: if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { fwd := m.forwardScreen.list[m.forwardScreen.selected] - m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name) - m.confirmAction = func() tea.Cmd { - return func() tea.Msg { - return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)} - } - } - m.screen = screenConfirm + m.confirmForwardDelete(fwd) return m, nil } case tea.KeyCtrlE, tea.KeyEnter: @@ -1231,13 +1307,7 @@ func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "d", "D": if m.forwardScreen != nil && m.forwardScreen.selected >= 0 && m.forwardScreen.selected < len(m.forwardScreen.list) { fwd := m.forwardScreen.list[m.forwardScreen.selected] - m.confirmMsg = fmt.Sprintf("Delete forward %q?", fwd.Name) - m.confirmAction = func() tea.Cmd { - return func() tea.Msg { - return forwardDeletedMsg{id: fwd.ID, err: DeleteForward(fwd.ID)} - } - } - m.screen = screenConfirm + m.confirmForwardDelete(fwd) return m, nil } } @@ -1262,9 +1332,8 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tunnelScreen = nil return m, nil case tea.KeyCtrlD: - if m.tunnelScreen != nil { - return m, m.tunnelScreen.stopSelected() - } + m.confirmTunnelStop() + return m, nil case tea.KeyCtrlR: if m.tunnelScreen != nil { return m, m.tunnelScreen.loadTunnels() @@ -1272,9 +1341,8 @@ func (m *tuiModel) updateTunnelManager(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyRunes: switch msg.String() { case "d", "D", "s", "S": - if m.tunnelScreen != nil { - return m, m.tunnelScreen.stopSelected() - } + m.confirmTunnelStop() + return m, nil case "r", "R": if m.tunnelScreen != nil { 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) { + if m.confirm == nil { + m.screen = screenList + return m, nil + } + if m.confirm.pending { + return m, nil + } switch msg.Type { case tea.KeyEsc: - m.screen = screenList - m.confirmMsg = "" - m.confirmAction = nil + return m.cancelConfirm() + case tea.KeyTab, tea.KeyShiftTab, tea.KeyLeft, tea.KeyRight: + if m.confirm.focus == confirmCancel { + m.confirm.focus = confirmAccept + } else { + m.confirm.focus = confirmCancel + } return m, nil case tea.KeyEnter: - if m.confirmAction != nil { - action := m.confirmAction - m.confirmMsg = "" - m.confirmAction = nil - return m, action() + if m.confirm.focus == confirmCancel { + return m.cancelConfirm() } + return m.acceptConfirm() case tea.KeyRunes: switch msg.String() { case "y", "Y": - if m.confirmAction != nil { - action := m.confirmAction - m.confirmMsg = "" - m.confirmAction = nil - return m, action() - } + return m.acceptConfirm() case "n", "N": - m.screen = screenList - m.confirmMsg = "" - m.confirmAction = nil - return m, nil + return m.cancelConfirm() } } return m, nil } func (m *tuiModel) viewConfirm() string { + if m.confirm == nil { + return "" + } var b strings.Builder - b.WriteString(titleStyle.Render("Confirm")) + b.WriteString(titleStyle.Render(m.confirm.title)) 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") + 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{ - {Key: "Enter / Y", Action: "yes"}, - {Key: "Esc / N", Action: "no"}, + {Key: "Tab", Action: "choose"}, + {Key: "Enter", Action: "activate"}, + {Key: "Esc", Action: "cancel"}, }, m.width)) 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) { updated, _ := m.fullHelp.Update(msg) if fh, ok := updated.(*fullHelpModel); ok { diff --git a/internal/tui/confirm_test.go b/internal/tui/confirm_test.go new file mode 100644 index 0000000..48426dd --- /dev/null +++ b/internal/tui/confirm_test.go @@ -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) + } + }) +} diff --git a/internal/tui/forward.go b/internal/tui/forward.go index 5537b47..ed13cdc 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -131,6 +131,10 @@ func (m *forwardScreenModel) View() string { } 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{ {Key: "Ctrl+A (a)", Action: "add"}, {Key: "Ctrl+E/Enter", Action: "edit"}, diff --git a/internal/tui/tunnel.go b/internal/tui/tunnel.go index 5646243..9a5737c 100644 --- a/internal/tui/tunnel.go +++ b/internal/tui/tunnel.go @@ -97,6 +97,10 @@ func (m *tunnelScreenModel) View() string { var b strings.Builder b.WriteString(m.list.View()) 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{ {Key: "Ctrl+D (s)", Action: "stop tunnel"}, {Key: "Ctrl+R (r)", Action: "refresh"},