fix: make tui destructive actions safe
This commit is contained in:
parent
5391612a14
commit
84a61d4895
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue