- 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>
389 lines
9.6 KiB
Go
389 lines
9.6 KiB
Go
// 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
|
||
}
|