/*
* Copyright (C) 2025 Jonni Liljamo <jonni@liljamo.com>
*
* This file is licensed under GPL-3.0-only, see NOTICE and LICENSE for
* more information.
*/
// nolint
package main
import (
"bytes"
"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"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"gopkg.in/yaml.v3"
)
type state int
const (
stateTargetSelect state = iota
stateActionSelect
stateActionConfirm
stateActionExec
stateViewOutput
)
type model struct {
width int
height int
err string
out string
exec string
confirmed bool
state state
targetList list.Model
selectedTarget types.TargetItem
actionList list.Model
selectedAction types.ActionItem
actionListKeys *types.ActionListKeyMap
outputViewport viewport.Model
outputViewportKeys *types.OutputViewportKeyMap
outputHelp help.Model
confirmDialog confirm.Model
}
type execFinishedMsg struct {
err error
out bytes.Buffer
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "enter":
switch m.state {
case stateTargetSelect:
i, ok := m.targetList.SelectedItem().(types.TargetItem)
if ok {
m.selectedTarget = i
m.state = stateActionSelect
}
case stateActionSelect:
a, ok := m.actionList.SelectedItem().(types.ActionItem)
if ok {
exec, err := a.ExecString(m.selectedTarget)
if err != nil {
m.err = err.Error()
} else {
m.exec = exec
if a.Confirm {
m.state = stateActionConfirm
return m, nil
}
m.state = stateActionExec
}
}
}
case "q":
switch m.state {
case stateTargetSelect:
return m, tea.Quit
case stateActionSelect:
if m.actionList.FilterState() != list.Filtering {
m.actionList.ResetSelected()
m.state = stateTargetSelect
}
case stateViewOutput:
m.state = stateActionSelect
}
case "c":
switch m.state {
case stateActionSelect:
if m.actionList.FilterState() != list.Filtering {
if os.Getenv("WAYLAND_DISPLAY") != "" {
a, ok := m.actionList.SelectedItem().(types.ActionItem)
if ok {
execString, err := a.ExecString(m.selectedTarget)
if err != nil {
m.err = err.Error()
} else {
err := exec.Command("wl-copy", execString).Run()
if err != nil {
m.err = err.Error()
}
}
}
}
}
}
case "o":
switch m.state {
case stateActionSelect:
m.state = stateViewOutput
}
}
case execFinishedMsg:
m.out = string(msg.out.Bytes())
m.outputViewport.SetContent(m.out)
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 stateTargetSelect:
var cmd tea.Cmd
m.targetList, cmd = m.targetList.Update(msg)
return m, cmd
case stateActionSelect:
var cmd tea.Cmd
m.actionList, cmd = m.actionList.Update(msg)
return m, cmd
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
}
return m, nil
}
func (m model) View() string {
s := strings.Builder{}
switch m.state {
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 {
s.WriteString("error in ExecString(): ")
s.WriteString(err.Error())
} else {
s.WriteString(styles.ActionExecString.Render(execString))
}
}
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
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 != "" {
s.WriteString("\n")
s.WriteString(styles.Error.Render(m.err))
}
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, styles.TopLevel.Render(s.String()))
}
type config struct {
Targets []types.TargetItem `yaml:"targets"`
Actions []types.ActionItem `yaml:"actions"`
}
func main() {
config := config{}
configContent, err := os.ReadFile("./tamma.yaml")
if err != nil {
fmt.Printf("failed to read config file: %s\n", err.Error())
os.Exit(1)
}
err = yaml.Unmarshal(configContent, &config)
if err != nil {
fmt.Printf("failed to unmarshal config file: %s\n", err.Error())
os.Exit(1)
}
targetListItems := []list.Item{}
for _, i := range config.Targets {
targetListItems = append(targetListItems, i)
}
targetListH := min(len(targetListItems), 10)
targetListKeys := types.NewTargetListKeyMap()
targetList := list.New(targetListItems, types.TargetItemDelegate{}, 48, targetListH+4)
targetList.Title = "Select target:"
targetList.SetShowHelp(true)
targetList.SetFilteringEnabled(true)
targetList.SetShowStatusBar(false)
targetList.DisableQuitKeybindings()
targetList.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
targetListKeys.Quit,
}
}
targetList.AdditionalFullHelpKeys = func() []key.Binding {
return []key.Binding{
targetListKeys.Quit,
}
}
targetList.Styles.Title = styles.ListTitle
targetList.Styles.PaginationStyle = styles.ListPaginaton
actionListItems := []list.Item{}
for _, i := range config.Actions {
if i.A == nil {
i.A = map[string]any{}
}
actionListItems = append(actionListItems, i)
}
actionListH := min(len(actionListItems), 10)
actionListKeys := types.NewActionListKeyMap()
actionList := list.New(actionListItems, types.ActionItemDelegate{}, 48, actionListH+4)
actionList.Title = "Select action?"
actionList.SetShowHelp(true)
actionList.SetFilteringEnabled(true)
actionList.SetShowStatusBar(false)
actionList.DisableQuitKeybindings()
actionList.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
actionListKeys.Back,
actionListKeys.CopyCommand,
actionListKeys.ViewOutput,
}
}
actionList.AdditionalFullHelpKeys = func() []key.Binding {
return []key.Binding{
actionListKeys.Back,
actionListKeys.CopyCommand,
actionListKeys.ViewOutput,
}
}
actionList.Styles.Title = styles.ListTitle
actionList.Styles.PaginationStyle = styles.ListPaginaton
outputViewport := viewport.New(20, 20)
outputViewport.SetHorizontalStep(6) // 6 will be default in bubbles v2
outputViewport.Style = styles.ViewportStyle
outputViewportKeys := types.NewOutputViewportKeyMap()
outputHelp := help.New()
confirmDialog := confirm.New()
p := tea.NewProgram(model{
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)
os.Exit(1)
}
os.Exit(0)
}