motd-assist/internal/ui/update.go
Tronax 303c49b484
feat: drop-in support for custom templates
User templates can now be added without rebuilding: drop a script into
~/.config/motd-assist/templates/ and reference it from a preset via
"install": "<filename>". Same-name files override built-ins; new files
are picked up by the running TUI via 'r'. `motd-assist templates` lists
known templates and creates the drop-in directory.

Apply plans now embed the resolved template content (Plan.ResolveContents)
so the sudo/root process never needs to read user config; snapshots store
installed script content so restore works under sudo as well. Validation
rejects plans with unresolved content.
2026-08-31 10:31:58 +02:00

261 lines
5.6 KiB
Go
Raw Permalink 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
}
// Vorlagen-Inhalte jetzt auflösen (User-Templates liegen im eigenen
// $HOME); der sudo-Prozess kann sie später nicht mehr lesen.
if err := plan.ResolveContents(); err != nil {
m.status = err.Error()
return m, nil
}
m.applying = true
m.lastSummary = plan.Summary()
return m, applyExec(plan)
}