From 8f9c9ff19483ee85459cf48e3bea226089fb9b84 Mon Sep 17 00:00:00 2001 From: Jonni Liljamo Date: Sat, 3 May 2025 18:23:41 +0300 Subject: [PATCH] feat: optional confirm dialog, other rendering changes --- README.md | 5 ++ components/confirm/confirm.go | 92 +++++++++++++++++++++++++ components/confirm/keymap.go | 44 ++++++++++++ main.go | 125 +++++++++++++++++++++------------- styles/styles.go | 12 +++- types/action.go | 1 + 6 files changed, 227 insertions(+), 52 deletions(-) create mode 100644 components/confirm/confirm.go create mode 100644 components/confirm/keymap.go diff --git a/README.md b/README.md index df38a36..7c7e2fa 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ See the example below, but in a nutshell: - `exectemplate` is a template for a command to be ran in a shell, it will be ran with `sh -c`. The template can access `.A.target.Name` and anything in `.A.target.Data`. + - Optionally, you can provde `confirm: true` to have a dialog to confirm the + command before running it. ### Example ```yaml @@ -33,4 +35,7 @@ targets: actions: - name: ssh exectemplate: ssh {{ .A.target.Data.user }}@{{ .A.target.Data.host }} + - name: ssh (confirm) + exectemplate: ssh {{ .A.target.Data.user }}@{{ .A.target.Data.host }} + confirm: true ``` diff --git a/components/confirm/confirm.go b/components/confirm/confirm.go new file mode 100644 index 0000000..1aca462 --- /dev/null +++ b/components/confirm/confirm.go @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2025 Jonni Liljamo + * + * This file is licensed under GPL-3.0-only, see NOTICE and LICENSE for + * more information. + */ + +// Package confirm contains a confirm dialog component for Bubble Tea. +package confirm + +import ( + "strings" + + "github.com/charmbracelet/bubbles/help" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Result is is a message containing the result of the confirm dialog. +type Result struct { + Choice bool +} + +// Model is the Bubble Tea model for this confirm dialog component. +type Model struct { + keymap *KeyMap + help help.Model + dialogStyle lipgloss.Style + selectedYesStyle lipgloss.Style + selectedNoStyle lipgloss.Style + choice bool +} + +// New returns a new Model. +func New() Model { + return Model{ + keymap: NewKeyMap(), + help: help.New(), + dialogStyle: lipgloss.NewStyle(). + AlignHorizontal(lipgloss.Center). + PaddingLeft(4), + selectedYesStyle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("113")), + selectedNoStyle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("167")), + choice: false, + } +} + +// Init is part of the tea.Model interface. +func (m Model) Init() tea.Cmd { + return nil +} + +// Update is part of the tea.Model interface. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case m.keymap.Submit.Keys()[0]: + return m, func() tea.Msg { + return Result{Choice: m.choice} + } + case m.keymap.Back.Keys()[0]: + return m, func() tea.Msg { + return Result{Choice: false} + } + case "left", "h", "right", "l": + m.choice = !m.choice + } + } + + return m, nil +} + +// View is part of the tea.Model interface. +func (m Model) View() string { + s := strings.Builder{} + dialog := strings.Builder{} + dialog.WriteString("Confirm: ") + if m.choice { + dialog.WriteString(" no") + dialog.WriteString(m.selectedYesStyle.Render(" >yes")) + } else { + dialog.WriteString(m.selectedNoStyle.Render(">no")) + dialog.WriteString(" yes") + } + dialog.WriteString("\n") + s.WriteString(m.dialogStyle.Render(dialog.String())) + s.WriteString(m.help.View(m.keymap)) + return s.String() +} diff --git a/components/confirm/keymap.go b/components/confirm/keymap.go new file mode 100644 index 0000000..33646d3 --- /dev/null +++ b/components/confirm/keymap.go @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2025 Jonni Liljamo + * + * This file is licensed under GPL-3.0-only, see NOTICE and LICENSE for + * more information. + */ + +package confirm + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines key bindings for the output viewport. +type KeyMap struct { + Submit key.Binding + Back key.Binding +} + +// NewKeyMap returns a new KeyMap. +func NewKeyMap() *KeyMap { + return &KeyMap{ + Submit: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "submit"), + ), + Back: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "back"), + ), + } +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Submit, k.Back} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Submit, k.Back}, + } +} diff --git a/main.go b/main.go index 5eab76f..a453b43 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,9 @@ import ( "fmt" "os" "os/exec" + "strings" + "git.src.quest/~skye/tamma/components/confirm" "git.src.quest/~skye/tamma/styles" "git.src.quest/~skye/tamma/types" "github.com/charmbracelet/bubbles/help" @@ -28,9 +30,11 @@ import ( type state int const ( - targetSelect state = iota - actionSelect - viewOutput + stateTargetSelect state = iota + stateActionSelect + stateActionConfirm + stateActionExec + stateViewOutput ) type model struct { @@ -38,6 +42,8 @@ type model struct { height int err string out string + exec string + confirmed bool state state targetList list.Model selectedTarget types.TargetItem @@ -47,6 +53,7 @@ type model struct { outputViewport viewport.Model outputViewportKeys *types.OutputViewportKeyMap outputHelp help.Model + confirmDialog confirm.Model } type execFinishedMsg struct { @@ -70,43 +77,43 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "enter": switch m.state { - case targetSelect: + case stateTargetSelect: i, ok := m.targetList.SelectedItem().(types.TargetItem) if ok { m.selectedTarget = i - m.state = actionSelect + m.state = stateActionSelect } - case actionSelect: + case stateActionSelect: a, ok := m.actionList.SelectedItem().(types.ActionItem) if ok { - execString, err := a.ExecString(m.selectedTarget) + exec, err := a.ExecString(m.selectedTarget) if err != nil { m.err = err.Error() } else { - c := exec.Command("sh", "-c", execString) - var out bytes.Buffer - c.Stdout = &out - return m, tea.ExecProcess(c, func(err error) tea.Msg { - return execFinishedMsg{err, out} - }) + m.exec = exec + if a.Confirm { + m.state = stateActionConfirm + return m, nil + } + m.state = stateActionExec } } } case "q": switch m.state { - case targetSelect: + case stateTargetSelect: return m, tea.Quit - case actionSelect: + case stateActionSelect: if m.actionList.FilterState() != list.Filtering { m.actionList.ResetSelected() - m.state = targetSelect + m.state = stateTargetSelect } - case viewOutput: - m.state = actionSelect + case stateViewOutput: + m.state = stateActionSelect } case "c": switch m.state { - case actionSelect: + case stateActionSelect: if m.actionList.FilterState() != list.Filtering { if os.Getenv("WAYLAND_DISPLAY") != "" { a, ok := m.actionList.SelectedItem().(types.ActionItem) @@ -126,8 +133,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "o": switch m.state { - case actionSelect: - m.state = viewOutput + case stateActionSelect: + m.state = stateViewOutput } } case execFinishedMsg: @@ -136,18 +143,36 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.err = msg.err.Error() } + case confirm.Result: + if msg.Choice { + m.state = stateActionExec + } else { + m.state = stateActionSelect + } } switch m.state { - case targetSelect: + case stateTargetSelect: var cmd tea.Cmd m.targetList, cmd = m.targetList.Update(msg) return m, cmd - case actionSelect: + case stateActionSelect: var cmd tea.Cmd m.actionList, cmd = m.actionList.Update(msg) return m, cmd - case viewOutput: + case stateActionConfirm: + var cmd tea.Cmd + m.confirmDialog, cmd = m.confirmDialog.Update(msg) + return m, cmd + case stateActionExec: + m.state = stateActionSelect + c := exec.Command("sh", "-c", m.exec) + var out bytes.Buffer + c.Stdout = &out + return m, tea.ExecProcess(c, func(err error) tea.Msg { + return execFinishedMsg{err, out} + }) + case stateViewOutput: var cmd tea.Cmd m.outputViewport, cmd = m.outputViewport.Update(msg) return m, cmd @@ -157,43 +182,42 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { - tlw := lipgloss.Width(styles.TopLevel.Render("")) - 2 - - msg := "\n" + s := strings.Builder{} switch m.state { - case targetSelect: - msg += m.targetList.View() - msg += "\n" - case actionSelect: - alb := m.actionList.View() - //alw := lipgloss.Width(alb) - msg += alb - msg += "\n" - // NOTE: type asserting here will return nil if we've filtered the list to - // have no results, thus this check is needed. + case stateTargetSelect: + s.WriteString(m.targetList.View()) + case stateActionSelect: + s.WriteString(m.actionList.View()) + s.WriteString("\n") selectedAction, ok := m.actionList.SelectedItem().(types.ActionItem) if ok { execString, err := selectedAction.ExecString(m.selectedTarget) if err != nil { - msg += fmt.Sprintf("error in ExecString(): %s\n", err.Error()) + s.WriteString("error in ExecString(): ") + s.WriteString(err.Error()) + } else { + s.WriteString(styles.ActionExecString.Render(execString)) } - msg += styles.ActionExecString.Width(tlw).SetString(execString).String() } - case viewOutput: + case stateActionConfirm: + s.WriteString("Confirm that you want to run the following:\n\n") + s.WriteString(styles.ActionExecString.Render(m.exec)) + s.WriteString("\n\n") + s.WriteString(m.confirmDialog.View()) + case stateViewOutput: m.outputViewport.Height = m.height - 20 m.outputViewport.Width = m.width - 2 - msg += m.outputViewport.View() - msg += "\n\npager-like keys apply, plus the following:\n" - msg += m.outputHelp.View(m.outputViewportKeys) - msg += "\n" + s.WriteString(m.outputViewport.View()) + s.WriteString("\n\npager-like keys apply, plus the following:\n") + s.WriteString(m.outputHelp.View(m.outputViewportKeys)) } if m.err != "" { - msg += m.err + s.WriteString("\n") + s.WriteString(styles.Error.Render(m.err)) } - msg = styles.TopLevel.Render(msg) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, styles.TopLevel.Render(s.String())) } type config struct { @@ -253,7 +277,7 @@ func main() { actionListH := min(len(actionListItems), 10) actionListKeys := types.NewActionListKeyMap() actionList := list.New(actionListItems, types.ActionItemDelegate{}, 48, actionListH+4) - actionList.Title = "What shall we do today?" + actionList.Title = "Select action?" actionList.SetShowHelp(true) actionList.SetFilteringEnabled(true) actionList.SetShowStatusBar(false) @@ -281,14 +305,17 @@ func main() { outputViewportKeys := types.NewOutputViewportKeyMap() outputHelp := help.New() + confirmDialog := confirm.New() + p := tea.NewProgram(model{ - state: targetSelect, + state: stateTargetSelect, targetList: targetList, actionList: actionList, actionListKeys: actionListKeys, outputViewport: outputViewport, outputViewportKeys: outputViewportKeys, outputHelp: outputHelp, + confirmDialog: confirmDialog, }, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Printf("An error occured while running: %v\n", err) diff --git a/styles/styles.go b/styles/styles.go index ed73605..de7b9e8 100644 --- a/styles/styles.go +++ b/styles/styles.go @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Jonni Liljamo + * Copyright (C) 2025 Jonni Liljamo * * This file is licensed under GPL-3.0-only, see NOTICE and LICENSE for * more information. @@ -19,10 +19,13 @@ import ( // nolint var ( + AvailableWidth = 78 + TopLevel = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("059")). - Width(78) // border takes 1 column each side + Width(AvailableWidth). + Padding(1) ListItem = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("102")) ListItemSelected = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) @@ -31,7 +34,10 @@ var ( ActionExecString = lipgloss.NewStyle(). AlignHorizontal(lipgloss.Center). - Foreground(lipgloss.Color("145")) + Foreground(lipgloss.Color("145")). + Width(AvailableWidth) ViewportStyle = lipgloss.NewStyle().MarginLeft(2) + + Error = lipgloss.NewStyle().Foreground(lipgloss.Color("167")) ) diff --git a/types/action.go b/types/action.go index b80abd8..196d4c0 100644 --- a/types/action.go +++ b/types/action.go @@ -23,6 +23,7 @@ import ( type ActionItem struct { Name string ExecTemplate string + Confirm bool A map[string]any } -- 2.44.1