xetup/internal/tui/tui.go
X9 Dev 5b53b2a0d6 Add per-feature toggles to PS scripts and Go TUI
- 02-software.ps1: wrap wingetInstalls, pdfDefault, ateraAgent in Get-Feature guards
- 03-system-registry.ps1: add Get-Feature, restructure into 5 gated blocks
  (systemTweaks, edgePolicies, oneDriveUninstall, powercfg, proxyDisable)
- 04-default-profile.ps1: add Get-Feature, wrap taskbarTweaks, startMenuTweaks,
  explorerTweaks; add missing explorerTweaks code (ShowRecent, ShowFrequent, FullPath)
- 11-dell-update.ps1: add Get-Feature, split update run by drivers/bios feature flags
- runner.go: add Feature/StepFeatures/SelectableItem/AllSelectableItems for TUI
- config.go: add Features type and defaults for all 4 gated steps
- tui.go: use AllSelectableItems for MultiSelect, build Features map in startRun,
  remove unused stepFeaturesMap variable
- xetup.exe: Windows amd64 build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 11:11:51 +02:00

416 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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