motd-assist/internal/ui/view.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

336 lines
8.3 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 (
"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)
}