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
This commit is contained in:
commit
d56659e21a
34 changed files with 3260 additions and 0 deletions
255
internal/ui/update.go
Normal file
255
internal/ui/update.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue