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
110
internal/ui/commands.go
Normal file
110
internal/ui/commands.go
Normal 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
182
internal/ui/model.go
Normal 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
109
internal/ui/model_test.go
Normal 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
56
internal/ui/styles.go
Normal 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
255
internal/ui/update.go
Normal 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
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