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:
Tronax 2026-08-24 14:19:28 +02:00
commit d56659e21a
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
34 changed files with 3260 additions and 0 deletions

110
internal/ui/commands.go Normal file
View file

@ -0,0 +1,110 @@
package ui
import (
"os"
"os/exec"
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
type discoverMsg struct {
scripts []motd.Script
host motd.HostInfo
err error
}
type presetsMsg struct {
presets []preset.Preset
err error
}
type previewMsg struct {
version int
text string
}
type applyDoneMsg struct{ err error }
func discoverCmd(root string) tea.Cmd {
return func() tea.Msg {
scripts, err := motd.Discover(root)
return discoverMsg{scripts: scripts, host: motd.DetectHost(root), err: err}
}
}
func presetsCmd() tea.Cmd {
return func() tea.Msg {
presets, err := preset.LoadAll()
return presetsMsg{presets: presets, err: err}
}
}
// previewCmd rendert die Arbeitskopie als Live-Vorschau (asynchron, damit
// langsame Scripte die TUI nicht blockieren).
func (m *Model) previewCmd() tea.Cmd {
m.previewBusy = true
m.previewVersion++
version := m.previewVersion
width := m.previewWidth()
scripts := make([]motd.PreviewScript, 0, len(m.working))
for _, r := range m.working {
if r.Remove {
continue
}
ps := motd.PreviewScript{Name: r.Name, Enabled: r.Enabled}
if r.Exists {
ps.Path = filepath.Join(motd.MotdDir(m.root), r.Name)
} else {
ps.Source = r.Install
}
scripts = append(scripts, ps)
}
return func() tea.Msg {
return previewMsg{version: version, text: motd.Render(scripts, width)}
}
}
// previewWidth ist die nutzbare Breite innerhalb der Preview-Umrandung.
func (m *Model) previewWidth() int {
w := m.width - 4 // Rahmen (2) + Padding (2)
if w < 40 {
w = 40
}
if w > 100 {
w = 100
}
return w
}
// applyExec handelt das Terminal an sudo weiter (interaktive
// Passwortabfrage) und startet danach die TUI weiter.
func applyExec(plan motd.Plan) tea.Cmd {
planFile, err := plan.WriteFile()
if err != nil {
return func() tea.Msg { return applyDoneMsg{err: err} }
}
self, err := os.Executable()
if err != nil {
os.Remove(planFile)
return func() tea.Msg { return applyDoneMsg{err: err} }
}
var cmd *exec.Cmd
if motd.IsRoot() {
cmd = exec.Command(self, "apply-state", planFile)
} else {
sudo, err := exec.LookPath("sudo")
if err != nil {
os.Remove(planFile)
return func() tea.Msg { return applyDoneMsg{err: err} }
}
cmd = exec.Command(sudo, "-p", "[motd-assist] Passwort für %p: ", self, "apply-state", planFile)
}
return tea.ExecProcess(cmd, func(err error) tea.Msg {
os.Remove(planFile)
return applyDoneMsg{err: err}
})
}

182
internal/ui/model.go Normal file
View file

@ -0,0 +1,182 @@
// Package ui implementiert die Bubbletea-TUI von motd-assist.
package ui
import (
"os"
"path/filepath"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/assets"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
type pane int
const (
panePresets pane = iota
paneScripts
panePreview
)
type mode int
const (
modeNormal mode = iota
modeSaveName
modeHelp
)
// row ist die Arbeitskopie eines Scripts inkl. geplanter Installation.
type row struct {
Name string
Enabled bool
Install string // gebündelte Vorlage, solange noch nicht installiert
Bundled bool // installiert und entspricht einer Vorlage
Remove bool // Deinstallation geplant (Preset-Wechsel)
Exists bool // Datei existiert bereits
}
// Model ist das Bubbletea-Model der TUI.
type Model struct {
root string
host motd.HostInfo
current []motd.Script
working []row
presets []preset.Preset
// presetMatches markiert Presets, die dem aktiven Ist-Zustand entsprechen.
presetMatches map[string]bool
focus pane
mode mode
cursor int // Zeiger in der Script-Liste
presetIdx int
previewOff int
preview string
previewBusy bool
previewVersion int
status string
err error
applying bool
lastSummary string
width, height int
ready bool
input textinput.Model
spinner spinner.Model
}
// New erzeugt das Model für ein root-Dateisystem (normalerweise "/").
func New(root string) Model {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = dimStyle
ti := textinput.New()
ti.Placeholder = "preset-name"
ti.CharLimit = 48
return Model{
root: root,
focus: panePresets,
presetMatches: map[string]bool{},
spinner: sp,
input: ti,
}
}
// Init startet Discovery und Spinner.
func (m Model) Init() tea.Cmd {
return tea.Batch(discoverCmd(m.root), presetsCmd(), m.spinner.Tick)
}
// desiredEntries wandelt die Arbeitskopie in Soll-Einträge um.
func (m *Model) desiredEntries() []motd.DesiredEntry {
out := make([]motd.DesiredEntry, 0, len(m.working))
for _, r := range m.working {
out = append(out, motd.DesiredEntry{
Name: r.Name,
Enabled: r.Enabled,
Install: r.Install,
Remove: r.Remove,
})
}
return out
}
// currentPlan berechnet den ausstehenden Plan (Ist → Arbeitskopie).
func (m *Model) currentPlan() motd.Plan {
return motd.Diff(m.current, m.desiredEntries())
}
func rowsFromCurrent(scripts []motd.Script) []row {
rows := make([]row, len(scripts))
for i, s := range scripts {
rows[i] = row{Name: s.Name, Enabled: s.Enabled, Bundled: s.Bundled, Exists: true}
}
return rows
}
func rowsFromDesired(entries []motd.DesiredEntry, current []motd.Script) []row {
curByName := make(map[string]motd.Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
rows := make([]row, 0, len(entries))
for _, e := range entries {
r := row{Name: e.Name, Enabled: e.Enabled, Remove: e.Remove}
if c, exists := curByName[e.Name]; exists {
r.Exists = true
r.Bundled = c.Bundled
} else if e.Install != "" {
r.Install = e.Install
}
rows = append(rows, r)
}
return rows
}
// presetFromWorking serialisiert die Arbeitskopie in ein Preset; für
// installierte gebündelte Scripte wird die Vorlage als Install-Quelle
// hinterlegt, damit das Preset auf anderen Maschinen tragbar bleibt.
func (m *Model) presetFromWorking(name string) preset.Preset {
p := preset.Preset{Name: name}
for _, r := range m.working {
if r.Remove {
continue
}
e := preset.Entry{Name: r.Name, Enabled: r.Enabled}
switch {
case r.Install != "":
e.Install = r.Install
case r.Bundled:
if b, err := os.ReadFile(filepath.Join(motd.MotdDir(m.root), r.Name)); err == nil {
if src, ok := assets.MatchScript(string(b)); ok {
e.Install = src
}
}
}
p.Scripts = append(p.Scripts, e)
}
return p
}
// refreshPresetMatches prüft, welche Presets dem Ist-Zustand entsprechen.
func (m *Model) refreshPresetMatches() {
m.presetMatches = make(map[string]bool, len(m.presets))
for _, p := range m.presets {
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})
}
desired := motd.DesiredFromPreset(m.current, entries)
m.presetMatches[p.Name] = motd.Diff(m.current, desired).Empty()
}
}

109
internal/ui/model_test.go Normal file
View file

@ -0,0 +1,109 @@
package ui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
func mustUpdate(m Model, msg tea.Msg) Model {
next, _ := m.Update(msg)
return next.(Model)
}
func newTestModel(t *testing.T) Model {
t.Helper()
m := New(t.TempDir())
m = mustUpdate(m, tea.WindowSizeMsg{Width: 120, Height: 40})
return m
}
func TestViewRendersAllPanes(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x/00-header", Enabled: true},
{Name: "10-sysinfo", Path: "/x/10-sysinfo", Enabled: false},
}
m = mustUpdate(m, discoverMsg{scripts: scripts, host: motd.HostInfo{Hostname: "myhost", Distro: "Ubuntu 24.04", PAM: true}})
m = mustUpdate(m, previewMsg{version: m.previewVersion, text: "Willkommen auf myhost"})
view := m.View()
for _, want := range []string{"Presets", "Scripte", "Live-Vorschau", "00-header", "10-sysinfo", "Willkommen auf myhost", "myhost"} {
if !strings.Contains(view, want) {
t.Errorf("View enthält %q nicht", want)
}
}
}
func TestToggleAndPlan(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x/00-header", Enabled: true},
{Name: "10-sysinfo", Path: "/x/10-sysinfo", Enabled: false},
}
m = mustUpdate(m, discoverMsg{scripts: scripts})
// Fokus auf Scripte wechseln und erstes Script ausschalten.
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeySpace})
plan := m.currentPlan()
if len(plan.Chmod) != 1 || plan.Chmod[0].Name != "00-header" || plan.Chmod[0].Enable {
t.Errorf("Chmod = %+v, want 00-header aus", plan.Chmod)
}
if markers := m.markersByRow(); markers["00-header"] != "aus" {
t.Errorf("Marker = %q, want aus", markers["00-header"])
}
}
func TestMoveRowChangesOrder(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x", Enabled: true},
{Name: "10-sysinfo", Path: "/x", Enabled: true},
}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) // nach unten
if m.working[0].Name != "10-sysinfo" || m.working[1].Name != "00-header" {
t.Errorf("Reihenfolge = %v, %v", m.working[0].Name, m.working[1].Name)
}
if plan := m.currentPlan(); len(plan.Renames) != 2 {
t.Errorf("Renames = %+v, want 2", plan.Renames)
}
}
func TestLoadPresetMarksPending(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{{Name: "00-header", Path: "/x", Enabled: false}}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, presetsMsg{presets: []preset.Preset{{Name: "p1", Scripts: []preset.Entry{{Name: "00-header", Enabled: true}}}}})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) // erstes Preset laden
if plan := m.currentPlan(); len(plan.Chmod) != 1 || !plan.Chmod[0].Enable {
t.Errorf("Plan = %+v, want 00-header an", plan)
}
if m.status == "" {
t.Error("Statusmeldung nach Preset-Laden erwartet")
}
}
func TestDiscardResetsWorking(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{{Name: "00-header", Path: "/x", Enabled: true}}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeySpace}) // ausschalten
if m.currentPlan().Empty() {
t.Fatal("Plan sollte Änderungen haben")
}
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyEsc})
if !m.currentPlan().Empty() {
t.Errorf("nach esc sollte der Plan leer sein, ist %+v", m.currentPlan())
}
}

56
internal/ui/styles.go Normal file
View file

@ -0,0 +1,56 @@
package ui
import "github.com/charmbracelet/lipgloss"
var (
colorAccent = lipgloss.Color("75")
colorBright = lipgloss.Color("81")
colorGood = lipgloss.Color("114")
colorWarn = lipgloss.Color("214")
colorBad = lipgloss.Color("203")
colorDim = lipgloss.Color("244")
colorBorder = lipgloss.Color("240")
focusedTitle = lipgloss.NewStyle().Bold(true).Foreground(colorBright)
dimStyle = lipgloss.NewStyle().Foreground(colorDim)
goodStyle = lipgloss.NewStyle().Foreground(colorGood)
badStyle = lipgloss.NewStyle().Foreground(colorBad)
warnStyle = lipgloss.NewStyle().Foreground(colorWarn)
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(colorBright)
helpStyle = lipgloss.NewStyle().Foreground(colorDim)
)
// paneBox baut den Rahmen-Stil für ein Pane mit gegebener Außenbreite/-höhe.
// lipgloss zählt Padding zur Breite, den Rahmen nicht daher w-2/h-2.
func paneBox(focused bool, w, h int) lipgloss.Style {
s := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
PaddingLeft(1).
PaddingRight(1)
if focused {
s = s.BorderForeground(colorAccent)
}
if w > 0 {
s = s.Width(w - 2)
}
if h > 0 {
s = s.Height(h - 2)
}
return s
}
// truncate schneidet s Anzeigebreite auf w und hängt … an.
func truncate(s string, w int) string {
if w <= 0 {
return ""
}
r := []rune(s)
if len(r) <= w {
return s
}
if w == 1 {
return "…"
}
return string(r[:w-1]) + "…"
}

255
internal/ui/update.go Normal file
View 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)
}

336
internal/ui/view.go Normal file
View file

@ -0,0 +1,336 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"motd-assist/internal/motd"
)
// View rendert das komplette Layout: Preset- und Script-Pane oben,
// Live-Vorschau unten, Statuszeile am unteren Rand.
func (m Model) View() string {
if !m.ready {
return "motd-assist startet…"
}
switch m.mode {
case modeHelp:
return m.helpView()
case modeSaveName:
return m.saveView()
}
if m.err != nil {
return m.errorView()
}
const statusH = 1
previewH := max(6, (m.height-statusH)*2/5)
topH := m.height - statusH - previewH
if topH < 5 {
topH = max(3, m.height-statusH-3)
previewH = m.height - statusH - topH
}
presetsW := 26
if m.width < 90 {
presetsW = 22
}
scriptsW := m.width - presetsW - 1
top := lipgloss.JoinHorizontal(lipgloss.Top,
m.presetsView(presetsW, topH),
" ",
m.scriptsView(scriptsW, topH),
)
body := lipgloss.JoinVertical(lipgloss.Left,
top,
m.previewView(m.width, previewH),
m.statusView(),
)
return body
}
func (m Model) presetsView(w, h int) string {
inner := w - 4 // Rahmen (2) + Padding (2)
var b strings.Builder
b.WriteString(focusedTitle.Render("Presets"))
b.WriteString("\n")
visible := h - 3 // Rahmen (2) + Titel (1)
start := 0
if m.presetIdx >= visible {
start = m.presetIdx - visible + 1
}
for i := start; i < len(m.presets) && i < start+visible; i++ {
p := m.presets[i]
cursor := " "
name := truncate(p.Name, inner-5)
if m.focus == panePresets && i == m.presetIdx {
cursor = "▸ "
b.WriteString(cursorStyle.Render(cursor + name))
} else {
b.WriteString(cursor + name)
}
if m.presetMatches[p.Name] {
b.WriteString(" ")
b.WriteString(goodStyle.Render("✓"))
}
b.WriteString("\n")
}
if len(m.presets) == 0 {
b.WriteString(dimStyle.Render("(keine Presets)"))
b.WriteString("\n")
}
return paneBox(m.focus == panePresets, w, h).Render(b.String())
}
func (m Model) scriptsView(w, h int) string {
inner := w - 4
var b strings.Builder
title := "Scripte · /etc/update-motd.d"
b.WriteString(focusedTitle.Render(truncate(title, inner)))
b.WriteString("\n")
markers := m.markersByRow()
visible := h - 3
start := 0
if m.cursor >= visible {
start = m.cursor - visible + 1
}
for i := start; i < len(m.working) && i < start+visible; i++ {
b.WriteString(m.renderRow(m.working[i], markers[m.working[i].Name], inner, m.focus == paneScripts && i == m.cursor))
b.WriteString("\n")
}
if len(m.working) == 0 {
b.WriteString(dimStyle.Render("(leer)"))
b.WriteString("\n")
}
return paneBox(m.focus == paneScripts, w, h).Render(b.String())
}
func (m Model) renderRow(r row, marker string, inner int, selected bool) string {
var icon string
switch {
case r.Remove:
icon = badStyle.Render("✕")
case r.Enabled:
icon = goodStyle.Render("●")
default:
icon = dimStyle.Render("○")
}
name := r.Name
suffix := ""
if r.Install != "" {
suffix = " " + dimStyle.Render("+neu")
} else if r.Bundled {
suffix = " " + dimStyle.Render("·motd-assist")
}
if selected {
name = cursorStyle.Render(name)
} else if !r.Enabled && !r.Remove {
name = dimStyle.Render(name)
}
line := icon + " " + name + suffix
// Marker rechtsbündig, ohne ANSI-Breiten zu verreißen: Marker ist
// gefärbt, daher Breite ohne Escape-Codes einplanen.
markerPlain := marker
markerW := len([]rune(markerPlain))
used := lipgloss.Width(line)
pad := inner - used - markerW - 1
if pad < 0 {
pad = 0
}
if marker != "" {
style := warnStyle
switch marker {
case "neu", "an":
style = goodStyle
case "entfernt":
style = badStyle
default:
style = dimStyle
}
line += strings.Repeat(" ", pad) + style.Render(marker)
}
return line
}
// markersByRow liefert die ausstehenden Änderungen pro Script-Name.
func (m *Model) markersByRow() map[string]string {
mk := make(map[string]string)
plan := m.currentPlan()
for _, op := range plan.Installs {
if op.Enabled {
mk[op.Name] = "neu"
} else {
mk[op.Name] = "neu (aus)"
}
}
for _, op := range plan.Chmod {
if op.Enable {
mk[op.Name] = "an"
} else {
mk[op.Name] = "aus"
}
}
for _, op := range plan.Renames {
mk[op.From] = "→ " + op.To
}
for _, op := range plan.Removes {
mk[op.Name] = "entfernt"
}
return mk
}
func (m Model) previewView(w, h int) string {
inner := w - 4
var title string
switch {
case m.applying:
title = fmt.Sprintf("Live-Vorschau %s sudo läuft…", m.spinner.View())
case m.previewBusy:
title = fmt.Sprintf("Live-Vorschau %s aktualisiere…", m.spinner.View())
default:
if n := m.currentPlan().NumOps(); n > 0 {
title = fmt.Sprintf("Live-Vorschau · %s (a: anwenden, esc: verwerfen)",
warnStyle.Render(fmt.Sprintf("%d Änderungen ausstehend", n)))
} else {
title = "Live-Vorschau · " + goodStyle.Render("entspricht dem System")
}
}
var content string
if m.preview == "" {
content = dimStyle.Render("(keine Ausgabe Scripte aktivieren mit Leertaste)")
} else {
lines := strings.Split(m.preview, "\n")
off := min(m.previewOff, max(0, len(lines)-1))
end := off + h - 3
if end > len(lines) {
end = len(lines)
}
content = strings.Join(lines[off:end], "\n")
}
return paneBox(m.focus == panePreview, w, h).Render(
truncateAnsi(title, inner) + "\n" + content,
)
}
// truncateAnsi kürzt einen stilisierten Titel auf Anzeigebreite w.
func truncateAnsi(s string, w int) string {
if lipgloss.Width(s) <= w {
return s
}
var b strings.Builder
width := 0
inEsc := false
for _, r := range s {
switch {
case r == '\x1b':
inEsc = true
b.WriteRune(r)
case inEsc:
b.WriteRune(r)
if r == 'm' {
inEsc = false
}
default:
if width >= w {
continue
}
b.WriteRune(r)
width++
}
}
return b.String()
}
func (m Model) statusView() string {
pam := warnStyle.Render("pam_motd ✗")
if m.host.PAM {
pam = goodStyle.Render("pam_motd ✓")
}
priv := warnStyle.Render("sudo nötig")
if motd.IsRoot() {
priv = goodStyle.Render("root")
}
enabled := 0
for _, r := range m.working {
if r.Enabled && !r.Remove {
enabled++
}
}
left := fmt.Sprintf("%s · %s · %s · %d/%d aktiv · %s",
m.host.Hostname, m.host.Distro, pam, enabled, len(m.working), priv)
line := statusStyle(left)
if m.status != "" {
line += statusStyle(" │ ") + statusStyle(m.status)
}
return lipgloss.NewStyle().MaxWidth(m.width).Render(line)
}
func statusStyle(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Render(s)
}
func (m Model) errorView() string {
msg := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBad).
Padding(1, 2).
Width(min(60, m.width-4)).
Render(
badStyle.Render("MOTD-Verzeichnis nicht gefunden") + "\n\n" +
m.err.Error() + "\n\n" +
dimStyle.Render("motd-assist verwaltet /etc/update-motd.d (Ubuntu/Debian). "+
"Auf diesem System liegt kein update-motd.d vor.") + "\n\n" +
dimStyle.Render("q/esc: beenden · r: erneut versuchen"),
)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg)
}
func (m Model) helpView() string {
keys := [][2]string{
{"tab / shift+tab", "Panes wechseln (Presets · Scripte · Vorschau)"},
{"j / k, ↑ / ↓", "auswählen"},
{"enter / space", "Preset laden · Script an/aus"},
{"u / d", "Script in der Reihenfolge nach oben/unten"},
{"a", "Änderungen anwenden (sudo-Passwortabfrage)"},
{"esc", "Änderungen verwerfen"},
{"s", "aktuellen Zustand als Preset speichern"},
{"D", "ausgewähltes Preset löschen (nur eigene)"},
{"r", "Systemzustand neu einlesen"},
{"q", "beenden"},
}
var b strings.Builder
b.WriteString(focusedTitle.Render("Hilfe") + "\n\n")
for _, k := range keys {
b.WriteString(cursorStyle.Render(fmt.Sprintf("%-20s", k[0])) + " " + k[1] + "\n")
}
b.WriteString("\n" + dimStyle.Render("Presets: ~/.config/motd-assist/presets · Undo: motd-assist restore"))
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorAccent).
Padding(1, 2).
Render(b.String())
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}
func (m Model) saveView() string {
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorAccent).
Padding(1, 2).
Render(
focusedTitle.Render("Preset speichern") + "\n\n" +
m.input.View() + "\n\n" +
helpStyle.Render("enter: speichern · esc: abbrechen"),
)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}