M README.md => README.md +5 -0
@@ 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
```
A components/confirm/confirm.go => components/confirm/confirm.go +92 -0
@@ 0,0 1,92 @@
+/*
+ * Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * 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()
+}
A components/confirm/keymap.go => components/confirm/keymap.go +44 -0
@@ 0,0 1,44 @@
+/*
+ * Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * 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},
+ }
+}
M main.go => main.go +76 -49
@@ 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)
M styles/styles.go => styles/styles.go +9 -3
@@ 1,5 1,5 @@
/*
- * Copyright (C) 2024 Jonni Liljamo <jonni@liljamo.com>
+ * Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
*
* 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"))
)
M types/action.go => types/action.go +1 -0
@@ 23,6 23,7 @@ import (
type ActionItem struct {
Name string
ExecTemplate string
+ Confirm bool
A map[string]any
}