motd-assist/internal/ui/update.go
Tronax d56659e21a
feat: add motd-assist TUI for managing dynamic MOTDs
Initial Go project built with Bubble Tea and Lip Gloss:
- Live preview and management of /etc/update-motd.d scripts
- Embedded presets (ubuntu-default, minimal, server-dashboard)
- Bundled POSIX sh style scripts (brand header, sysinfo panel, footer)
- Snapshot/restore safety around applying changes
- CLI subcommands preview, apply, restore, list-presets
- Unit tests for detect, sanitize, and apply logic
2026-08-24 14:19:28 +02:00

255 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ui
import (
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
// Update behandelt Fenstergröße, Ergebnisse der Hintergrundkommandos und
// Tastendrücke.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ready = true
if w := m.width - 20; w > 10 {
m.input.Width = w
}
if len(m.working) > 0 {
return m, m.previewCmd() // Breite geändert → Preview neu rendern
}
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case discoverMsg:
if msg.err != nil {
m.err = msg.err
m.current, m.working = nil, nil
return m, nil
}
m.err = nil
m.current = msg.scripts
m.host = msg.host
m.working = rowsFromCurrent(msg.scripts)
if m.cursor >= len(m.working) {
m.cursor = max(0, len(m.working)-1)
}
m.refreshPresetMatches()
m.previewOff = 0
return m, m.previewCmd()
case presetsMsg:
if msg.err == nil {
m.presets = msg.presets
if m.presetIdx >= len(m.presets) {
m.presetIdx = max(0, len(m.presets)-1)
}
m.refreshPresetMatches()
}
return m, nil
case previewMsg:
if msg.version < m.previewVersion {
return m, nil // veraltetes Ergebnis, neuere Preview läuft schon
}
m.previewBusy = false
m.preview = msg.text
m.previewOff = 0
return m, nil
case applyDoneMsg:
m.applying = false
if msg.err != nil {
m.status = "Anwenden fehlgeschlagen: " + msg.err.Error()
} else {
m.status = "Angewendet: " + m.lastSummary
}
return m, discoverCmd(m.root)
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.mode {
case modeHelp:
m.mode = modeNormal
return m, nil
case modeSaveName:
switch msg.String() {
case "enter":
name := strings.TrimSpace(m.input.Value())
m.mode = modeNormal
m.input.Blur()
if name == "" {
return m, nil
}
if _, err := preset.SaveUser(m.presetFromWorking(name)); err != nil {
m.status = "Speichern fehlgeschlagen: " + err.Error()
} else {
m.status = "Preset \"" + name + "\" gespeichert"
}
return m, presetsCmd()
case "esc", "ctrl+c":
m.mode = modeNormal
m.input.Blur()
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
key := msg.String()
switch key {
case "q", "ctrl+c":
return m, tea.Quit
case "tab":
m.focus = (m.focus + 1) % 3
return m, nil
case "shift+tab":
m.focus = (m.focus + 2) % 3
case "?":
m.mode = modeHelp
return m, nil
case "r":
m.status = ""
return m, discoverCmd(m.root)
case "esc":
if m.err != nil {
return m, tea.Quit
}
m.working = rowsFromCurrent(m.current)
m.previewOff = 0
m.status = "Änderungen verworfen"
return m, m.previewCmd()
case "s":
if m.err != nil {
return m, nil
}
m.mode = modeSaveName
m.input.SetValue("")
m.input.Focus()
return m, textinput.Blink
case "a":
return m.applyWorking()
case "D":
if len(m.presets) == 0 {
return m, nil
}
p := m.presets[m.presetIdx]
if err := preset.DeleteUser(p.Name); err != nil {
m.status = err.Error()
} else {
m.status = "Preset \"" + p.Name + "\" gelöscht"
}
return m, presetsCmd()
}
switch m.focus {
case panePresets:
switch key {
case "j", "down":
if m.presetIdx < len(m.presets)-1 {
m.presetIdx++
}
case "k", "up":
if m.presetIdx > 0 {
m.presetIdx--
}
case "enter", " ":
return m.loadPreset()
}
case paneScripts:
switch key {
case "j", "down":
if m.cursor < len(m.working)-1 {
m.cursor++
}
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
case " ":
if len(m.working) == 0 {
return m, nil
}
r := &m.working[m.cursor]
if r.Remove {
return m, nil
}
r.Enabled = !r.Enabled
return m, m.previewCmd()
case "u":
return m.moveRow(-1)
case "d":
return m.moveRow(1)
}
case panePreview:
lines := strings.Count(m.preview, "\n") + 1
switch key {
case "j", "down":
m.previewOff = min(m.previewOff+1, max(0, lines-1))
case "k", "up":
if m.previewOff > 0 {
m.previewOff--
}
}
}
return m, nil
}
func (m Model) loadPreset() (tea.Model, tea.Cmd) {
if len(m.presets) == 0 {
return m, nil
}
p := m.presets[m.presetIdx]
entries := make([]motd.DesiredEntry, 0, len(p.Scripts))
for _, e := range p.Scripts {
entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install})
}
m.working = rowsFromDesired(motd.DesiredFromPreset(m.current, entries), m.current)
m.cursor = 0
m.previewOff = 0
m.status = "Preset \"" + p.Name + "\" geladen mit 'a' anwenden"
return m, m.previewCmd()
}
func (m Model) moveRow(delta int) (tea.Model, tea.Cmd) {
i, j := m.cursor, m.cursor+delta
if i < 0 || i >= len(m.working) || j < 0 || j >= len(m.working) {
return m, nil
}
m.working[i], m.working[j] = m.working[j], m.working[i]
m.cursor = j
return m, m.previewCmd()
}
func (m Model) applyWorking() (tea.Model, tea.Cmd) {
if m.applying || m.err != nil {
return m, nil
}
plan := m.currentPlan()
if plan.Empty() {
m.status = "Keine Änderungen zum Anwenden"
return m, nil
}
m.applying = true
m.lastSummary = plan.Summary()
return m, applyExec(plan)
}