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
336 lines
8.3 KiB
Go
336 lines
8.3 KiB
Go
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)
|
||
}
|