// 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() } }