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
120 lines
2.9 KiB
Go
120 lines
2.9 KiB
Go
package motd
|
||
|
||
import (
|
||
"strings"
|
||
|
||
"github.com/mattn/go-runewidth"
|
||
)
|
||
|
||
type escapeKind int
|
||
|
||
const (
|
||
escNone escapeKind = iota
|
||
escSGR
|
||
)
|
||
|
||
// parseEscape klassifiziert eine Escape-Sequenz ab runes[0] == ESC und
|
||
// liefert die konsumierte Rune-Anzahl. Nur SGR (Farben) ist relevant.
|
||
func parseEscape(r []rune) (string, int, escapeKind) {
|
||
if len(r) < 2 {
|
||
return "", len(r), escNone
|
||
}
|
||
switch r[1] {
|
||
case '[': // CSI: Parameter (0x30–0x3F), Zwischenzeichen (0x20–0x2F), Final
|
||
j := 2
|
||
for j < len(r) && r[j] >= 0x30 && r[j] <= 0x3f {
|
||
j++
|
||
}
|
||
for j < len(r) && r[j] >= 0x20 && r[j] <= 0x2f {
|
||
j++
|
||
}
|
||
if j >= len(r) {
|
||
return "", len(r), escNone // unvollständig
|
||
}
|
||
if r[j] == 'm' {
|
||
return string(r[:j+1]), j + 1, escSGR
|
||
}
|
||
return "", j + 1, escNone
|
||
case ']', 'P', 'X', '^', '_': // OSC/DCS/SOS/PM/APC: bis BEL oder ST
|
||
for j := 2; j < len(r); j++ {
|
||
if r[j] == 0x07 {
|
||
return "", j + 1, escNone
|
||
}
|
||
if r[j] == '\x1b' && j+1 < len(r) && r[j+1] == '\\' {
|
||
return "", j + 2, escNone
|
||
}
|
||
}
|
||
return "", len(r), escNone
|
||
case '(', ')', '*', '+', '#', '%': // Charset-/Zeichensatz-Wechsel
|
||
if len(r) >= 3 {
|
||
return "", 3, escNone
|
||
}
|
||
return "", len(r), escNone
|
||
default:
|
||
return "", 2, escNone
|
||
}
|
||
}
|
||
|
||
// Sanitize bereinigt Script-Output für die Anzeige in der TUI: behalten
|
||
// werden SGR-Farbsequenzen und Zeilenumbrüche; Cursor-Steuerung, OSC,
|
||
// Charset-Switches und andere Steuerzeichen werden entfernt. Jede Zeile
|
||
// wird auf width Spellen begrenzt und bei Farbnutzung mit Reset
|
||
// abgeschlossen, damit die Pane-Umrandung nicht verfärbt wird.
|
||
func Sanitize(s string, width int) string {
|
||
if width <= 0 {
|
||
width = 80
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(s))
|
||
lineW, hasSGR := 0, false
|
||
|
||
runes := []rune(s)
|
||
for i := 0; i < len(runes); i++ {
|
||
r := runes[i]
|
||
switch {
|
||
case r == '\x1b':
|
||
seq, n, kind := parseEscape(runes[i:])
|
||
i += n - 1
|
||
if kind == escSGR {
|
||
b.WriteString(seq)
|
||
hasSGR = true
|
||
}
|
||
case r == '\n':
|
||
if hasSGR && !endsWithReset(&b) {
|
||
b.WriteString("\x1b[0m")
|
||
}
|
||
b.WriteByte('\n')
|
||
lineW, hasSGR = 0, false
|
||
case r == '\r', r == '\a', r == '\b', r == '\v', r == '\f':
|
||
// ignorieren
|
||
case r == '\t':
|
||
for k := 0; k < 4 && lineW < width; k++ {
|
||
b.WriteByte(' ')
|
||
lineW++
|
||
}
|
||
case r < 0x20 || r == 0x7f:
|
||
// übrige Steuerzeichen entfernen
|
||
default:
|
||
if lineW >= width {
|
||
continue // Zeile abgeschnitten
|
||
}
|
||
w := runewidth.RuneWidth(r)
|
||
if lineW+w > width {
|
||
lineW = width // breites Zeichen passt nicht mehr → Schnittstelle
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
lineW += w
|
||
}
|
||
}
|
||
out := b.String()
|
||
if hasSGR && !strings.HasSuffix(out, "\x1b[0m") {
|
||
out += "\x1b[0m" // letzte Zeile ohne Newline: offene Farbe schließen
|
||
}
|
||
return strings.TrimRight(out, "\n")
|
||
}
|
||
|
||
// endsWithReset prüft, ob der Builder bereits auf Farbe zurückgesetzt ist.
|
||
func endsWithReset(b *strings.Builder) bool {
|
||
return strings.HasSuffix(b.String(), "\x1b[0m")
|
||
}
|