a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
436 lines
11 KiB
Go
436 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/zzet/gortex/internal/progress"
|
|
)
|
|
|
|
// dashboardTick paces the mesh + active-spinner redraw at 12 fps — enough for
|
|
// smooth animation, cheap for the terminal. The mesh has 12 perimeter cells;
|
|
// one tick per ~80ms gives a one-second full revolution.
|
|
const dashboardTickInterval = 80 * time.Millisecond
|
|
|
|
type dashTickMsg time.Time
|
|
|
|
func dashTick() tea.Cmd {
|
|
return tea.Tick(dashboardTickInterval, func(t time.Time) tea.Msg { return dashTickMsg(t) })
|
|
}
|
|
|
|
// StageState tracks one row in the dashboard's stage list.
|
|
type StageState int
|
|
|
|
const (
|
|
StagePending StageState = iota
|
|
StageActive
|
|
StageDone
|
|
StageFailed
|
|
)
|
|
|
|
// DashStage is one row in the dashboard's stage list. Sub is appended after
|
|
// the label in dim text — typically "1,338 files" or "92/267" — and updates
|
|
// live while the stage is Active.
|
|
type DashStage struct {
|
|
Label string
|
|
State StageState
|
|
Sub string
|
|
}
|
|
|
|
// DashStat is a key/value row shown in the dashboard's bottom strip (graph
|
|
// counters: nodes, edges, languages …). Values format themselves with thousand
|
|
// separators so the strip stays scannable as numbers grow.
|
|
type DashStat struct {
|
|
Label string
|
|
Value int64
|
|
Unit string // optional suffix, e.g. "files"
|
|
}
|
|
|
|
// Dashboard is a stateful multi-pane progress dashboard model: live mesh
|
|
// spinner on the left, header (title + elapsed + active label) on the right,
|
|
// stage list with ✓/⠋/◌ markers, and a stats strip across the bottom.
|
|
//
|
|
// It's a bubbletea Model so callers can wrap it in tea.NewProgram for a full
|
|
// alt-screen experience, but every mutator is also safe to call from worker
|
|
// goroutines via the tea.Program.Send channel (Set* messages below).
|
|
type Dashboard struct {
|
|
title string
|
|
stages []DashStage
|
|
stats []DashStat
|
|
active int // index of the current StageActive row (-1 = none)
|
|
startAt time.Time
|
|
|
|
tick int
|
|
width int
|
|
finished bool
|
|
err error
|
|
}
|
|
|
|
// NewDashboard builds a Dashboard with the given title and an ordered stage
|
|
// list. All stages start as StagePending; call SetActive / SetDone / SetFail
|
|
// (or send the equivalent messages to the running tea.Program) to advance.
|
|
func NewDashboard(title string, stages []string) *Dashboard {
|
|
rows := make([]DashStage, len(stages))
|
|
for i, s := range stages {
|
|
rows[i] = DashStage{Label: s, State: StagePending}
|
|
}
|
|
return &Dashboard{
|
|
title: title,
|
|
stages: rows,
|
|
active: -1,
|
|
startAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// --- bubbletea messages -------------------------------------------------
|
|
|
|
// DashSetActiveMsg moves the cursor to the named stage and marks any prior
|
|
// active stage as done.
|
|
type DashSetActiveMsg struct {
|
|
Stage string
|
|
Sub string
|
|
}
|
|
|
|
// DashSubMsg updates the sub-label of the currently active stage without
|
|
// changing which stage is active.
|
|
type DashSubMsg struct{ Sub string }
|
|
|
|
// DashDoneMsg marks the named stage done and clears the active cursor (the
|
|
// next SetActive will pick the new one).
|
|
type DashDoneMsg struct {
|
|
Stage string
|
|
Sub string // optional final stat line, e.g. "1,338 files"
|
|
}
|
|
|
|
// DashFailMsg marks the named stage failed and stops the dashboard.
|
|
type DashFailMsg struct {
|
|
Stage string
|
|
Err error
|
|
}
|
|
|
|
// DashStatsMsg replaces the bottom stats strip with the given rows.
|
|
type DashStatsMsg struct{ Stats []DashStat }
|
|
|
|
// DashFinishMsg stops the dashboard with a final state.
|
|
type DashFinishMsg struct {
|
|
Err error
|
|
}
|
|
|
|
// --- bubbletea Model ---------------------------------------------------
|
|
|
|
// Init kicks off the redraw ticker.
|
|
func (d *Dashboard) Init() tea.Cmd { return dashTick() }
|
|
|
|
// Update advances state per message. Defensive: unknown messages bounce off.
|
|
func (d *Dashboard) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch m := msg.(type) {
|
|
case dashTickMsg:
|
|
_ = m
|
|
d.tick++
|
|
return d, dashTick()
|
|
case tea.WindowSizeMsg:
|
|
d.width = m.Width
|
|
return d, nil
|
|
case DashSetActiveMsg:
|
|
d.applySetActive(m.Stage, m.Sub)
|
|
return d, nil
|
|
case DashSubMsg:
|
|
d.applySub(m.Sub)
|
|
return d, nil
|
|
case DashDoneMsg:
|
|
d.applyDone(m.Stage, m.Sub)
|
|
return d, nil
|
|
case DashFailMsg:
|
|
d.applyFail(m.Stage, m.Err)
|
|
return d, tea.Quit
|
|
case DashStatsMsg:
|
|
d.stats = m.Stats
|
|
return d, nil
|
|
case DashFinishMsg:
|
|
d.finishAll(m.Err)
|
|
return d, tea.Quit
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (d *Dashboard) applySetActive(stage, sub string) {
|
|
idx := d.findStage(stage)
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
if d.active >= 0 && d.active != idx && d.stages[d.active].State == StageActive {
|
|
d.stages[d.active].State = StageDone
|
|
}
|
|
d.stages[idx].State = StageActive
|
|
d.stages[idx].Sub = sub
|
|
d.active = idx
|
|
}
|
|
|
|
func (d *Dashboard) applySub(sub string) {
|
|
if d.active < 0 || d.active >= len(d.stages) {
|
|
return
|
|
}
|
|
d.stages[d.active].Sub = sub
|
|
}
|
|
|
|
func (d *Dashboard) applyDone(stage, sub string) {
|
|
idx := d.findStage(stage)
|
|
if idx < 0 {
|
|
return
|
|
}
|
|
d.stages[idx].State = StageDone
|
|
if sub != "" {
|
|
d.stages[idx].Sub = sub
|
|
}
|
|
if d.active == idx {
|
|
d.active = -1
|
|
}
|
|
}
|
|
|
|
func (d *Dashboard) applyFail(stage string, err error) {
|
|
idx := d.findStage(stage)
|
|
if idx < 0 {
|
|
idx = d.active
|
|
}
|
|
if idx >= 0 && idx < len(d.stages) {
|
|
d.stages[idx].State = StageFailed
|
|
if err != nil {
|
|
d.stages[idx].Sub = err.Error()
|
|
}
|
|
}
|
|
d.err = err
|
|
d.finished = true
|
|
}
|
|
|
|
func (d *Dashboard) finishAll(err error) {
|
|
for i := range d.stages {
|
|
if d.stages[i].State == StageActive {
|
|
if err != nil {
|
|
d.stages[i].State = StageFailed
|
|
if d.stages[i].Sub == "" {
|
|
d.stages[i].Sub = err.Error()
|
|
}
|
|
} else {
|
|
d.stages[i].State = StageDone
|
|
}
|
|
}
|
|
}
|
|
d.err = err
|
|
d.finished = true
|
|
d.active = -1
|
|
}
|
|
|
|
// View renders the dashboard as it stands. Layout:
|
|
//
|
|
// ● ● ● gortex init 00:08 elapsed
|
|
// ● · · ● Indexing repository
|
|
// ● · ● ●
|
|
// ● · · ● ✓ Discover files 1,338 files
|
|
// ● ● ● ✓ Parse Go 728 files
|
|
// ⠋ Parse TypeScript 92/267
|
|
// ◌ Build call graph
|
|
// ◌ Generate skills
|
|
// ◌ Configure adapters
|
|
//
|
|
// nodes 18,432 · edges 142,876
|
|
func (d *Dashboard) View() string {
|
|
mesh := progress.MeshLogo(d.tick)
|
|
|
|
elapsed := time.Since(d.startAt).Truncate(time.Second).String()
|
|
header := lipgloss.JoinHorizontal(
|
|
lipgloss.Top,
|
|
progress.StyleStrong.Render(d.title),
|
|
progress.StyleHint.Render(" "+elapsed+" elapsed"),
|
|
)
|
|
subhead := ""
|
|
if d.active >= 0 && d.active < len(d.stages) {
|
|
subhead = progress.StyleSub.Render(d.stages[d.active].Label)
|
|
} else if d.finished {
|
|
if d.err != nil {
|
|
subhead = progress.StyleErr.Render("failed: " + d.err.Error())
|
|
} else {
|
|
subhead = progress.StyleOK.Render("done")
|
|
}
|
|
}
|
|
|
|
headerCol := lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
header,
|
|
subhead,
|
|
)
|
|
topRow := lipgloss.JoinHorizontal(lipgloss.Top, mesh, " ", headerCol)
|
|
|
|
var stageRows []string
|
|
for _, s := range d.stages {
|
|
stageRows = append(stageRows, d.renderStageRow(s))
|
|
}
|
|
body := strings.Join(stageRows, "\n")
|
|
|
|
// Indent the stage list so it lines up under the headerCol (mesh is 9
|
|
// cells wide + 4 spaces of gutter = 13).
|
|
body = indent(body, 13)
|
|
|
|
stats := ""
|
|
if len(d.stats) > 0 {
|
|
stats = "\n" + indent(d.renderStats(), 13)
|
|
}
|
|
|
|
return topRow + "\n\n" + body + stats + "\n"
|
|
}
|
|
|
|
func (d *Dashboard) renderStageRow(s DashStage) string {
|
|
var marker string
|
|
switch s.State {
|
|
case StagePending:
|
|
marker = progress.StyleHint.Render("◌")
|
|
case StageActive:
|
|
marker = progress.StyleAccent.Render(activeSpinFrame(d.tick))
|
|
case StageDone:
|
|
marker = progress.StyleOK.Render("✓")
|
|
case StageFailed:
|
|
marker = progress.StyleErr.Render("✗")
|
|
}
|
|
label := s.Label
|
|
switch s.State {
|
|
case StageActive:
|
|
label = progress.StyleStrong.Render(label)
|
|
case StageDone:
|
|
label = progress.StyleVal.Render(label)
|
|
case StageFailed:
|
|
label = progress.StyleErr.Render(label)
|
|
default:
|
|
label = progress.StyleHint.Render(label)
|
|
}
|
|
out := marker + " " + padRight(label, 28)
|
|
if s.Sub != "" {
|
|
out += " " + progress.StyleHint.Render(s.Sub)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (d *Dashboard) renderStats() string {
|
|
var parts []string
|
|
for _, s := range d.stats {
|
|
v := progress.StyleStrong.Render(humanInt(s.Value))
|
|
label := progress.StyleHint.Render(s.Label)
|
|
unit := ""
|
|
if s.Unit != "" {
|
|
unit = " " + progress.StyleHint.Render(s.Unit)
|
|
}
|
|
parts = append(parts, label+" "+v+unit)
|
|
}
|
|
return progress.StyleSub.Render(strings.Join(parts, " · "))
|
|
}
|
|
|
|
func (d *Dashboard) findStage(name string) int {
|
|
for i, s := range d.stages {
|
|
if s.Label == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// braille spinner frames for the active stage marker. Same set bubbles ships
|
|
// for its "Dot" spinner; reproduced inline so we don't pull bubbles for one
|
|
// character.
|
|
var activeSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
|
|
func activeSpinFrame(tick int) string {
|
|
if tick < 0 {
|
|
tick = -tick
|
|
}
|
|
return activeSpinnerFrames[tick%len(activeSpinnerFrames)]
|
|
}
|
|
|
|
func indent(s string, n int) string {
|
|
pad := strings.Repeat(" ", n)
|
|
return pad + strings.ReplaceAll(s, "\n", "\n"+pad)
|
|
}
|
|
|
|
func humanInt(n int64) string {
|
|
if n < 0 {
|
|
return "-" + humanInt(-n)
|
|
}
|
|
if n < 1000 {
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
// thousands separator with ','.
|
|
digits := fmt.Sprintf("%d", n)
|
|
out := make([]byte, 0, len(digits)+len(digits)/3)
|
|
rem := len(digits) % 3
|
|
if rem > 0 {
|
|
out = append(out, digits[:rem]...)
|
|
if len(digits) > rem {
|
|
out = append(out, ',')
|
|
}
|
|
}
|
|
for i := rem; i < len(digits); i += 3 {
|
|
out = append(out, digits[i:i+3]...)
|
|
if i+3 < len(digits) {
|
|
out = append(out, ',')
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// --- driving from outside the model ------------------------------------
|
|
|
|
// DashboardController is a thread-safe handle a non-bubbletea worker can use
|
|
// to push messages into a running Dashboard's tea.Program. Construct via
|
|
// NewDashboardController; the wizard's runtime stores one on its model and
|
|
// hands it to the orchestration code that previously talked to Spinner.Set.
|
|
type DashboardController struct {
|
|
mu sync.Mutex
|
|
prog *tea.Program
|
|
}
|
|
|
|
// NewDashboardController returns a controller bound to the running program.
|
|
func NewDashboardController(p *tea.Program) *DashboardController {
|
|
return &DashboardController{prog: p}
|
|
}
|
|
|
|
// SetActive marks stage as the current active row, with sub as its detail.
|
|
func (c *DashboardController) SetActive(stage, sub string) {
|
|
c.send(DashSetActiveMsg{Stage: stage, Sub: sub})
|
|
}
|
|
|
|
// Sub updates only the sub-label of the currently active stage.
|
|
func (c *DashboardController) Sub(sub string) { c.send(DashSubMsg{Sub: sub}) }
|
|
|
|
// Done marks stage done with optional final sub-label.
|
|
func (c *DashboardController) Done(stage, sub string) {
|
|
c.send(DashDoneMsg{Stage: stage, Sub: sub})
|
|
}
|
|
|
|
// Fail marks stage failed and shuts the dashboard down.
|
|
func (c *DashboardController) Fail(stage string, err error) {
|
|
c.send(DashFailMsg{Stage: stage, Err: err})
|
|
}
|
|
|
|
// Stats replaces the bottom stats strip.
|
|
func (c *DashboardController) Stats(stats ...DashStat) {
|
|
c.send(DashStatsMsg{Stats: stats})
|
|
}
|
|
|
|
// Finish stops the dashboard. Pass err = nil for a clean done; non-nil err
|
|
// marks the active stage failed before quitting.
|
|
func (c *DashboardController) Finish(err error) {
|
|
c.send(DashFinishMsg{Err: err})
|
|
}
|
|
|
|
func (c *DashboardController) send(msg tea.Msg) {
|
|
c.mu.Lock()
|
|
prog := c.prog
|
|
c.mu.Unlock()
|
|
if prog == nil {
|
|
return
|
|
}
|
|
prog.Send(msg)
|
|
}
|