/*
* Copyright (C) 2024 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 (
"fmt"
"os"
"os/exec"
"git.src.quest/~skye/tamma/styles"
"git.src.quest/~skye/tamma/types"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"gopkg.in/yaml.v3"
)
type state int
const (
hostSelect state = iota
actionSelect
)
type model struct {
width int
height int
err string
state state
hostList list.Model
selectedHost types.HostItem
actionList list.Model
selectedAction types.ActionItem
actionListKeys *types.ActionListKeyMap
}
type execFinishedMsg struct{ err error }
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 hostSelect:
i, ok := m.hostList.SelectedItem().(types.HostItem)
if ok {
m.selectedHost = i
m.state = actionSelect
}
case actionSelect:
a, ok := m.actionList.SelectedItem().(types.ActionItem)
if ok {
execString, err := a.ExecString(m.selectedHost)
if err != nil {
m.err = err.Error()
} else {
c := exec.Command("sh", "-c", execString)
return m, tea.ExecProcess(c, func(err error) tea.Msg {
return execFinishedMsg{err}
})
}
}
}
case "q":
switch m.state {
case hostSelect:
return m, tea.Quit
case actionSelect:
if m.actionList.FilterState() != list.Filtering {
m.actionList.ResetSelected()
m.state = hostSelect
}
}
case "c":
switch m.state {
case actionSelect:
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.selectedHost)
if err != nil {
m.err = err.Error()
} else {
err := exec.Command("wl-copy", execString).Run()
if err != nil {
m.err = err.Error()
}
}
}
}
}
}
}
case execFinishedMsg:
if msg.err != nil {
m.err = msg.err.Error()
}
}
switch m.state {
case hostSelect:
var cmd tea.Cmd
m.hostList, cmd = m.hostList.Update(msg)
return m, cmd
case actionSelect:
var cmd tea.Cmd
m.actionList, cmd = m.actionList.Update(msg)
return m, cmd
}
return m, nil
}
func (m model) View() string {
tlw := lipgloss.Width(styles.TopLevel.Render("")) - 2
msg := "\n"
switch m.state {
case hostSelect:
msg += m.hostList.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.
selectedAction, ok := m.actionList.SelectedItem().(types.ActionItem)
if ok {
execString, err := selectedAction.ExecString(m.selectedHost)
if err != nil {
msg += fmt.Sprintf("error in ExecString(): %s\n", err.Error())
}
msg += styles.ActionExecString.Width(tlw).SetString(execString).String()
}
}
if m.err != "" {
msg += m.err
}
msg = styles.TopLevel.Render(msg)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg)
}
type config struct {
Hosts []types.HostItem `yaml:"hosts"`
Actions []types.ActionItem `yaml:"actions"`
// Wheter default actions should be enabled, defaults to true.
DefaultActions bool `yaml:"default_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)
}
hostListItems := []list.Item{}
for _, i := range config.Hosts {
hostListItems = append(hostListItems, i)
}
hostListH := len(hostListItems)
if hostListH > 10 {
hostListH = 10
}
hostListKeys := types.NewHostListKeyMap()
hostList := list.New(hostListItems, types.HostItemDelegate{}, 48, hostListH+4)
hostList.Title = "Select host:"
hostList.SetShowHelp(true)
hostList.SetFilteringEnabled(true)
hostList.SetShowStatusBar(false)
hostList.DisableQuitKeybindings()
hostList.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
hostListKeys.Quit,
}
}
hostList.AdditionalFullHelpKeys = func() []key.Binding {
return []key.Binding{
hostListKeys.Quit,
}
}
hostList.Styles.Title = styles.ListTitle
hostList.Styles.PaginationStyle = styles.ListPaginaton
actionListItems := []list.Item{}
if !config.DefaultActions {
actionListItems = []list.Item{
types.ActionItem{
Name: "ssh",
ExecTemplate: "ssh {{ .A.host.Data.user }}@{{ .A.host.IP }}",
A: map[string]interface{}{},
},
types.ActionItem{
Name: "remote switch",
ExecTemplate: "nixos-rebuild switch --flake \".?submodules=1#{{ .A.host.Name }}\" --target-host {{ .A.host.Data.user }}@{{ .A.host.IP }}",
A: map[string]interface{}{},
},
}
}
for _, i := range config.Actions {
if i.A == nil {
i.A = map[string]interface{}{}
}
actionListItems = append(actionListItems, i)
}
actionListH := len(actionListItems)
if actionListH > 10 {
actionListH = 10
}
actionListKeys := types.NewActionListKeyMap()
actionList := list.New(actionListItems, types.ActionItemDelegate{}, 48, actionListH+4)
actionList.Title = "What shall we do today?"
actionList.SetShowHelp(true)
actionList.SetFilteringEnabled(true)
actionList.SetShowStatusBar(false)
actionList.DisableQuitKeybindings()
actionList.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
actionListKeys.Back,
actionListKeys.CopyCommand,
}
}
actionList.AdditionalFullHelpKeys = func() []key.Binding {
return []key.Binding{
actionListKeys.Back,
actionListKeys.CopyCommand,
}
}
actionList.Styles.Title = styles.ListTitle
actionList.Styles.PaginationStyle = styles.ListPaginaton
p := tea.NewProgram(model{
state: hostSelect,
hostList: hostList,
actionList: actionList,
actionListKeys: actionListKeys,
}, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Printf("An error occured while running: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}