xetup/internal/tui/tui.go
X9 Dev 1198de3c49 feat: Go TUI launcher (xetup.exe) + spec page new-request section
- embed.go: root package exposes Scripts/Assets embed.FS
- internal/config: Config struct, Load/Save/Default
- internal/runner: Step list, Runner with context cancel, log streaming
- internal/tui: bubbletea model - huh form (phase 1) + live log view (phase 2) + summary (phase 3)
- cmd/xetup/main.go: main binary, extracts embedded content to tmpdir, runs TUI
- Builds to 5.2 MB xetup.exe (GOOS=windows GOARCH=amd64)

spec/index.html:
- arch-xetup section: mark 5 items flag-done (code now exists)
- Add "Nove nastaveni" section linked to Forgejo issue #15
- Add sidebar link for new-requests
- Add CSS + JS for request widget (loads/posts to issue #15 comments)

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

389 lines
9.6 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
selectedStepIDs []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 {
allSteps := runner.AllSteps()
opts := make([]huh.Option[string], len(allSteps))
for i, s := range allSteps {
opts[i] = huh.NewOption(s.Num+" - "+s.Name, s.ID).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 k provedeni (mezernikem odzaskrtnout)").
Options(opts...).
Value(&m.selectedStepIDs),
),
)
}
// --------------------------------------------------------------------------
// 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
// build step list with Enabled flag from the user's checklist
selected := make(map[string]bool, len(m.selectedStepIDs))
for _, id := range m.selectedStepIDs {
selected[id] = true
}
allSteps := runner.AllSteps()
steps := make([]runner.Step, len(allSteps))
for i, s := range allSteps {
s.Enabled = selected[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
}