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
255 lines
5.4 KiB
Go
255 lines
5.4 KiB
Go
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)
|
||
}
|