feat: Stage 1 scaffold — config, API client, placeholder TUI
Sets up the full project structure following the hactl/gokapi-tui architecture: strict Elm-pattern Bubbletea TUI, YAML config at ~/.config/terdut-tui/config.yaml, REST API client with Bearer auth, self-update via GitHub Releases, and a three-section tab placeholder (Alerts / Schedule / Users) that verifies server connectivity on startup.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
type keyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Tab key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
Refresh key.Binding
|
||||
Filter key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("◀/h", "prev")),
|
||||
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("▶/l", "next")),
|
||||
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch section")),
|
||||
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
|
||||
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
||||
Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.Down, k.Tab, k.Refresh, k.Quit}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Left, k.Right},
|
||||
{k.Tab, k.Enter, k.Escape},
|
||||
{k.Refresh, k.Filter, k.Quit},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/yeniklas/terdut-tui/internal/api"
|
||||
)
|
||||
|
||||
type section int
|
||||
|
||||
const (
|
||||
sectionAlerts section = iota
|
||||
sectionSchedule
|
||||
sectionUsers
|
||||
)
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
modeDashboard mode = iota
|
||||
modeDetail
|
||||
modeSchedule
|
||||
modeUsers
|
||||
)
|
||||
|
||||
// tea.Msg types
|
||||
|
||||
type connectedMsg struct{ serverURL string }
|
||||
type connectErrMsg struct{ err error }
|
||||
type tickMsg time.Time
|
||||
type clearStatusMsg struct{}
|
||||
|
||||
// Model holds all UI state.
|
||||
type Model struct {
|
||||
client *api.Client
|
||||
serverURL string
|
||||
refreshInterval time.Duration
|
||||
|
||||
activeSection section
|
||||
mode mode
|
||||
width int
|
||||
height int
|
||||
|
||||
connected bool
|
||||
err error
|
||||
statusMsg string
|
||||
|
||||
help help.Model
|
||||
keys keyMap
|
||||
}
|
||||
|
||||
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
|
||||
return Model{
|
||||
client: client,
|
||||
serverURL: serverURL,
|
||||
refreshInterval: refreshInterval,
|
||||
activeSection: sectionAlerts,
|
||||
mode: modeDashboard,
|
||||
help: help.New(),
|
||||
keys: keys,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return connectCmd(m.client)
|
||||
}
|
||||
|
||||
func connectCmd(client *api.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := client.HealthCheck(); err != nil {
|
||||
return connectErrMsg{err}
|
||||
}
|
||||
return connectedMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func tickCmd(interval time.Duration) tea.Cmd {
|
||||
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func clearStatusCmd() tea.Cmd {
|
||||
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
|
||||
return clearStatusMsg{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
colorPrimary = lipgloss.Color("69") // blue
|
||||
colorMuted = lipgloss.Color("240") // gray
|
||||
colorFiring = lipgloss.Color("196") // red
|
||||
colorResolved = lipgloss.Color("70") // green
|
||||
colorAccent = lipgloss.Color("214") // orange
|
||||
|
||||
styleHeader = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
styleTabActive = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("0")).
|
||||
Background(colorPrimary).
|
||||
Padding(0, 2)
|
||||
|
||||
styleTabInactive = lipgloss.NewStyle().
|
||||
Foreground(colorMuted).
|
||||
Padding(0, 2)
|
||||
|
||||
styleFooter = lipgloss.NewStyle().
|
||||
Foreground(colorMuted)
|
||||
|
||||
styleStatus = lipgloss.NewStyle().
|
||||
Foreground(colorAccent).
|
||||
Bold(true)
|
||||
|
||||
styleError = lipgloss.NewStyle().
|
||||
Foreground(colorFiring).
|
||||
Bold(true)
|
||||
|
||||
styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
|
||||
styleResolved = lipgloss.NewStyle().Foreground(colorResolved)
|
||||
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
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 connectedMsg:
|
||||
m.connected = true
|
||||
m.err = nil
|
||||
m.statusMsg = "Connected to " + m.serverURL
|
||||
return m, tea.Batch(tickCmd(m.refreshInterval), clearStatusCmd())
|
||||
|
||||
case connectErrMsg:
|
||||
m.connected = false
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
return m, tickCmd(m.refreshInterval)
|
||||
|
||||
case clearStatusMsg:
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch {
|
||||
case msg.String() == "q" || msg.String() == "ctrl+c":
|
||||
return m, tea.Quit
|
||||
|
||||
case msg.String() == "tab":
|
||||
m.activeSection = (m.activeSection + 1) % 3
|
||||
return m, nil
|
||||
|
||||
case msg.String() == "r":
|
||||
m.statusMsg = "Refreshing…"
|
||||
return m, tea.Batch(connectCmd(m.client), clearStatusCmd())
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var sectionNames = []string{"Alerts", "Schedule", "Users"}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
header := m.renderHeader()
|
||||
tabs := m.renderTabs()
|
||||
body := m.renderBody()
|
||||
footer := m.renderFooter()
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, tabs, body, footer)
|
||||
}
|
||||
|
||||
func (m Model) renderHeader() string {
|
||||
title := styleHeader.Render("terdut-tui")
|
||||
right := ""
|
||||
if m.serverURL != "" {
|
||||
right = styleMuted.Render(m.serverURL)
|
||||
}
|
||||
|
||||
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
return title + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
|
||||
func (m Model) renderTabs() string {
|
||||
var tabs []string
|
||||
for i, name := range sectionNames {
|
||||
if section(i) == m.activeSection {
|
||||
tabs = append(tabs, styleTabActive.Render(name))
|
||||
} else {
|
||||
tabs = append(tabs, styleTabInactive.Render(name))
|
||||
}
|
||||
}
|
||||
line := strings.Repeat("─", m.width)
|
||||
return strings.Join(tabs, "") + "\n" + styleMuted.Render(line)
|
||||
}
|
||||
|
||||
func (m Model) renderBody() string {
|
||||
bodyHeight := m.height - 5 // header + tabs + separator + footer + help
|
||||
if bodyHeight < 1 {
|
||||
bodyHeight = 1
|
||||
}
|
||||
|
||||
var content string
|
||||
if m.err != nil {
|
||||
content = styleError.Render(fmt.Sprintf("Error: %v", m.err))
|
||||
} else if !m.connected {
|
||||
content = styleMuted.Render("Connecting…")
|
||||
} else if m.statusMsg != "" {
|
||||
content = styleStatus.Render(m.statusMsg)
|
||||
} else {
|
||||
switch m.activeSection {
|
||||
case sectionAlerts:
|
||||
content = styleMuted.Render("Alert dashboard — coming in Stage 2")
|
||||
case sectionSchedule:
|
||||
content = styleMuted.Render("On-call schedule — coming in Stage 4")
|
||||
case sectionUsers:
|
||||
content = styleMuted.Render("User management — coming in Stage 5")
|
||||
}
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
padding := (bodyHeight - len(lines)) / 2
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
return strings.Repeat("\n", padding) + content
|
||||
}
|
||||
|
||||
func (m Model) renderFooter() string {
|
||||
helpView := m.help.ShortHelpView(m.keys.ShortHelp())
|
||||
return "\n" + styleFooter.Render(helpView)
|
||||
}
|
||||
Reference in New Issue
Block a user