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
336
internal/ui/view.go
Normal file
336
internal/ui/view.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue