// Package tui implements the interactive terminal UI for xetup. // Phase 1 – configuration form (huh embedded in bubbletea). // Phase 2 – live log view while PowerShell scripts run. // Phase 3 – final summary. package tui import ( "context" "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "git.xetup.x9.cz/x9/xetup/internal/config" "git.xetup.x9.cz/x9/xetup/internal/runner" ) // -------------------------------------------------------------------------- // Phase // -------------------------------------------------------------------------- type phase int const ( phaseForm phase = iota phaseRun phaseDone ) // -------------------------------------------------------------------------- // Async message types (runner → bubbletea) // -------------------------------------------------------------------------- type logMsg runner.LogLine type resultMsg runner.Result type doneMsg []runner.Result // -------------------------------------------------------------------------- // Model // -------------------------------------------------------------------------- // Model is the top-level bubbletea model. type Model struct { phase phase form *huh.Form runCfg runner.RunConfig cfg config.Config // form-bound values (huh writes here via pointer) pcName string pcDesc string productKey string profileType string selectedItemKeys []string // runner state r *runner.Runner cancel context.CancelFunc msgCh chan tea.Msg logs []runner.LogLine results []runner.Result // terminal dimensions width int height int } // NewModel returns the initial model pre-populated from cfg. func NewModel(cfg config.Config, runCfg runner.RunConfig) Model { m := Model{ cfg: cfg, runCfg: runCfg, pcName: cfg.Deployment.PCName, pcDesc: cfg.Deployment.PCDescription, productKey: cfg.Activation.ProductKey, profileType: cfg.Deployment.ProfileType, width: 80, height: 24, } m.form = buildForm(&m) return m } // -------------------------------------------------------------------------- // Form construction // -------------------------------------------------------------------------- func buildForm(m *Model) *huh.Form { allItems := runner.AllSelectableItems() opts := make([]huh.Option[string], len(allItems)) for i, item := range allItems { opts[i] = huh.NewOption(item.Label, item.Key).Selected(true) } return huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Jmeno pocitace"). Placeholder("napr. NB-KLIENT-01 (prazdne = neprejmenovat)"). Value(&m.pcName), huh.NewInput(). Title("Popis pocitace"). Placeholder("napr. PC recepce"). Value(&m.pcDesc), huh.NewInput(). Title("Product Key"). Placeholder("prazdne = OA3 / GVLK fallback"). Value(&m.productKey), huh.NewSelect[string](). Title("Profil"). Options( huh.NewOption("Default", "default"), huh.NewOption("Admin (Explorer, PS, Edge)", "admin"), huh.NewOption("User (Explorer, Edge)", "user"), ). Value(&m.profileType), ), huh.NewGroup( huh.NewMultiSelect[string](). Title("Kroky a nastaveni (mezernikem odzaskrtnout)"). Options(opts...). Value(&m.selectedItemKeys), ), ) } // -------------------------------------------------------------------------- // bubbletea interface // -------------------------------------------------------------------------- // Init implements tea.Model. func (m Model) Init() tea.Cmd { return m.form.Init() } // Update implements tea.Model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case tea.KeyMsg: switch msg.String() { case "ctrl+c": if m.cancel != nil { m.cancel() } return m, tea.Quit } if m.phase == phaseDone { return m, tea.Quit } // --- async runner messages --- case logMsg: m.logs = append(m.logs, runner.LogLine(msg)) return m, readMsg(m.msgCh) case resultMsg: m.results = append(m.results, runner.Result(msg)) return m, readMsg(m.msgCh) case doneMsg: m.results = []runner.Result(msg) m.phase = phaseDone return m, nil } // --- form phase --- if m.phase == phaseForm { form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } switch m.form.State { case huh.StateCompleted: return m.startRun() case huh.StateAborted: return m, tea.Quit } return m, cmd } return m, nil } // View implements tea.Model. func (m Model) View() string { switch m.phase { case phaseForm: return m.viewForm() case phaseRun: return m.viewRun() case phaseDone: return m.viewDone() } return "" } // -------------------------------------------------------------------------- // Phase transitions // -------------------------------------------------------------------------- // startRun applies form values and kicks off the runner goroutine. func (m Model) startRun() (Model, tea.Cmd) { // persist form values into config m.cfg.Deployment.PCName = m.pcName m.cfg.Deployment.PCDescription = m.pcDesc m.cfg.Activation.ProductKey = m.productKey m.cfg.Deployment.ProfileType = m.profileType m.runCfg.ProfileType = m.profileType // Index selected keys for fast lookup selectedSet := make(map[string]bool, len(m.selectedItemKeys)) for _, key := range m.selectedItemKeys { selectedSet[key] = true } // Build Features map: feature enabled = its composite key was selected features := make(config.Features) for _, item := range runner.AllSelectableItems() { if item.FeatureID == "" { continue } if features[item.StepID] == nil { features[item.StepID] = make(map[string]bool) } features[item.StepID][item.FeatureID] = selectedSet[item.Key] } m.cfg.Features = features // Determine which steps are enabled: // - step with features: enabled if at least one feature selected // - step without features: enabled if step key selected stepEnabled := make(map[string]bool) for _, item := range runner.AllSelectableItems() { if item.FeatureID == "" { stepEnabled[item.StepID] = selectedSet[item.Key] } else if selectedSet[item.Key] { // any feature selected enables the step stepEnabled[item.StepID] = true } } allSteps := runner.AllSteps() steps := make([]runner.Step, len(allSteps)) for i, s := range allSteps { s.Enabled = stepEnabled[s.ID] steps[i] = s } // set up channel and start runner ch := make(chan tea.Msg, 200) m.msgCh = ch m.phase = phaseRun ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel rc := m.runCfg r := runner.New( rc, func(l runner.LogLine) { ch <- logMsg(l) }, func(res runner.Result) { ch <- resultMsg(res) }, ) m.r = r go func() { results := r.Run(ctx, steps) ch <- doneMsg(results) }() return m, readMsg(ch) } // readMsg returns a tea.Cmd that blocks until the next message arrives on ch. func readMsg(ch <-chan tea.Msg) tea.Cmd { return func() tea.Msg { return <-ch } } // -------------------------------------------------------------------------- // Styles // -------------------------------------------------------------------------- var ( sTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#58a6ff")) sOK = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043")) sError = lipgloss.NewStyle().Foreground(lipgloss.Color("#da3633")) sWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("#d29922")) sStep = lipgloss.NewStyle().Foreground(lipgloss.Color("#58a6ff")) sMuted = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) sBold = lipgloss.NewStyle().Bold(true) ) // -------------------------------------------------------------------------- // Views // -------------------------------------------------------------------------- func (m Model) viewForm() string { header := sTitle.Render("xetup") + " " + sMuted.Render("Windows deployment – vyplnte konfiguraci") return header + "\n\n" + m.form.View() } func (m Model) viewRun() string { allSteps := runner.AllSteps() doneCount := len(m.results) totalCount := len(allSteps) var sb strings.Builder // header bar sb.WriteString(sTitle.Render("xetup") + " ") if doneCount < totalCount { s := allSteps[doneCount] sb.WriteString(sBold.Render(fmt.Sprintf("krok %d/%d", doneCount+1, totalCount))) sb.WriteString(" " + s.Num + " - " + s.Name) } else { sb.WriteString(sBold.Render("dokoncuji...")) } sb.WriteByte('\n') sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90))) sb.WriteByte('\n') // log lines – show last N to fit terminal maxLines := m.height - 5 if maxLines < 5 { maxLines = 5 } start := 0 if len(m.logs) > maxLines { start = len(m.logs) - maxLines } for _, l := range m.logs[start:] { sb.WriteString(styledLogLine(l)) sb.WriteByte('\n') } sb.WriteString(sMuted.Render("\nCtrl+C pro zastaveni")) return sb.String() } func (m Model) viewDone() string { var sb strings.Builder sb.WriteString(sTitle.Render("xetup ") + sBold.Render("hotovo") + "\n") sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)) + "\n\n") okCount, errCount, skipCount := 0, 0, 0 for _, res := range m.results { var prefix string switch res.Status { case "OK": okCount++ prefix = sOK.Render(" OK ") case "ERROR": errCount++ prefix = sError.Render(" ERROR ") default: skipCount++ prefix = sMuted.Render(" SKIPPED ") } sb.WriteString(prefix + res.Step.Num + " - " + res.Step.Name) if res.Elapsed > 0 { sb.WriteString(sMuted.Render(fmt.Sprintf(" (%s)", res.Elapsed.Round(time.Second)))) } sb.WriteByte('\n') } sb.WriteString("\n" + strings.Repeat("─", clamp(m.width, 20, 90)) + "\n") summary := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", okCount, errCount, skipCount) if errCount > 0 { sb.WriteString(sError.Render(summary)) } else { sb.WriteString(sOK.Render(summary)) } sb.WriteString(sMuted.Render("\n\nStisknete libovolnou klavesu pro ukonceni...")) return sb.String() } // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- func styledLogLine(l runner.LogLine) string { switch l.Level { case "OK": return sOK.Render(l.Text) case "ERROR": return sError.Render(l.Text) case "WARN": return sWarn.Render(l.Text) case "STEP": return sStep.Render(l.Text) default: return sMuted.Render(l.Text) } } func clamp(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v }