chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// Command demo drives the live progress renderer through the flagship
|
||||
// sequences so the animation can be eyeballed (and its byte stream captured)
|
||||
// without touching a real daemon or repo:
|
||||
//
|
||||
// go run ./internal/progress/demo # full showcase
|
||||
// go run ./internal/progress/demo -scene=track|enrich|fail
|
||||
// go run ./internal/progress/demo -plain # the --no-progress rendering
|
||||
//
|
||||
// It is a development aid, deliberately outside cmd/gortex so it never ships
|
||||
// in the binary.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/progress"
|
||||
)
|
||||
|
||||
type phase struct {
|
||||
run string
|
||||
done string
|
||||
target int64
|
||||
unit string
|
||||
note string
|
||||
dur time.Duration
|
||||
}
|
||||
|
||||
// The indexing storyboard: scan → parse → graph → cross-repo → communities.
|
||||
var phases = []phase{
|
||||
{"scanning workspace", "files discovered", 2431, "files", "", 900 * time.Millisecond},
|
||||
{"parsing sources", "symbols extracted", 48210, "symbols", "go · ts · py · sql · yaml", 1500 * time.Millisecond},
|
||||
{"building knowledge graph", "edges materialized", 612940, "edges", "", 1700 * time.Millisecond},
|
||||
{"resolving cross-repo edges", "cross-repo links", 1284, "xrepo", "", 1100 * time.Millisecond},
|
||||
{"detecting communities", "modules clustered", 7, "modules", "", 800 * time.Millisecond},
|
||||
}
|
||||
|
||||
func main() {
|
||||
scene := flag.String("scene", "all", "track|enrich|fail|all")
|
||||
plain := flag.Bool("plain", false, "render the --no-progress plain output")
|
||||
flag.Parse()
|
||||
|
||||
switch *scene {
|
||||
case "track":
|
||||
track(*plain)
|
||||
case "enrich":
|
||||
enrich(*plain)
|
||||
case "fail":
|
||||
fail(*plain)
|
||||
default:
|
||||
track(*plain)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
enrich(*plain)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fail(*plain)
|
||||
}
|
||||
}
|
||||
|
||||
func newTracker(plain bool, opts ...progress.TrackerOption) *progress.Tracker {
|
||||
if plain {
|
||||
opts = append(opts, progress.WithoutAnimation())
|
||||
}
|
||||
return progress.NewTracker(os.Stderr, opts...)
|
||||
}
|
||||
|
||||
// track is the banner-preset showcase: planned phases, counts easing up,
|
||||
// the mark lighting dot by dot, then the ready line.
|
||||
func track(plain bool) {
|
||||
tr := newTracker(plain, progress.WithLogo())
|
||||
tr.Start("gortex track .")
|
||||
steps := make([]*progress.Step, len(phases))
|
||||
for i, p := range phases {
|
||||
steps[i] = tr.AddStep(p.run)
|
||||
steps[i].SetUnit(p.unit)
|
||||
}
|
||||
for i, p := range phases {
|
||||
tr.StartStep(p.run)
|
||||
if p.note != "" {
|
||||
steps[i].Note(p.note)
|
||||
}
|
||||
countUp(steps[i], p.target, p.dur)
|
||||
steps[i].DoneAs(p.done)
|
||||
}
|
||||
tr.SetStatus("")
|
||||
tr.Done("ready", "indexed 2,431 files · 48,210 symbols · 1 repo tracked")
|
||||
}
|
||||
|
||||
// enrich is the compact preset driven purely through the Reporter interface —
|
||||
// what every existing spinner call site gets for free.
|
||||
func enrich(plain bool) {
|
||||
tr := newTracker(plain)
|
||||
tr.Start("gortex enrich all")
|
||||
stages := []struct {
|
||||
name string
|
||||
total int
|
||||
dur time.Duration
|
||||
}{
|
||||
{"stamping blame", 420, 700 * time.Millisecond},
|
||||
{"loading coverage", 96, 400 * time.Millisecond},
|
||||
{"linking releases", 31, 300 * time.Millisecond},
|
||||
}
|
||||
for _, s := range stages {
|
||||
start := time.Now()
|
||||
for time.Since(start) < s.dur {
|
||||
frac := float64(time.Since(start)) / float64(s.dur)
|
||||
tr.Report(s.name, int(frac*float64(s.total)), s.total)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
}
|
||||
tr.Report(s.name, s.total, s.total)
|
||||
}
|
||||
tr.Done("", "3 enrichment passes complete")
|
||||
}
|
||||
|
||||
// fail shows the failure state, including a log line above the live region.
|
||||
func fail(plain bool) {
|
||||
tr := newTracker(plain)
|
||||
tr.Start("gortex enrich coverage")
|
||||
s := tr.StartStep("parsing profile")
|
||||
countUp(s, 1200, 500*time.Millisecond)
|
||||
s.Done()
|
||||
tr.Logf("warning: 3 segments referenced files outside the repo")
|
||||
tr.StartStep("stamping nodes")
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
tr.Fail(errors.New("daemon connection lost"))
|
||||
}
|
||||
|
||||
// countUp eases a counter to target over dur with the design's cubic
|
||||
// ease-out, ticking faster than the frame rate so the animation stays smooth.
|
||||
func countUp(s *progress.Step, target int64, dur time.Duration) {
|
||||
start := time.Now()
|
||||
for {
|
||||
t := float64(time.Since(start)) / float64(dur)
|
||||
if t >= 1 {
|
||||
break
|
||||
}
|
||||
u := 1 - t
|
||||
eased := 1 - u*u*u
|
||||
s.Progress(int64(eased*float64(target)), target)
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
s.Progress(target, target)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phase enumerates the typed sub-phases of an index / enrich run, so
|
||||
// progress labels are consistent across reporters and surfaces instead of
|
||||
// ad-hoc strings. They double as human-readable stage labels.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhaseDiscover Phase = "discovering files"
|
||||
PhaseParse Phase = "parsing"
|
||||
PhaseResolve Phase = "resolving references"
|
||||
PhaseInfer Phase = "linking symbols"
|
||||
PhasePersist Phase = "persisting"
|
||||
)
|
||||
|
||||
// FormatElapsed renders a duration as a compact human string: "45s",
|
||||
// "5m 12s", "1h 20m".
|
||||
func FormatElapsed(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
|
||||
case d < time.Hour:
|
||||
m := int(d / time.Minute)
|
||||
s := int((d % time.Minute) / time.Second)
|
||||
return fmt.Sprintf("%dm %02ds", m, s)
|
||||
default:
|
||||
h := int(d / time.Hour)
|
||||
m := int((d % time.Hour) / time.Minute)
|
||||
return fmt.Sprintf("%dh %02dm", h, m)
|
||||
}
|
||||
}
|
||||
|
||||
// ETA returns the throughput (items/sec) and estimated time remaining given
|
||||
// a start time and within-phase counters. A zero/unknown total yields a
|
||||
// zero eta. Rate is 0 until some time and work have elapsed.
|
||||
func ETA(start time.Time, current, total int) (itemsPerSec float64, eta time.Duration) {
|
||||
elapsed := time.Since(start)
|
||||
if elapsed <= 0 || current <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
itemsPerSec = float64(current) / elapsed.Seconds()
|
||||
if total > current && itemsPerSec > 0 {
|
||||
remaining := float64(total - current)
|
||||
eta = time.Duration(remaining/itemsPerSec) * time.Second
|
||||
}
|
||||
return itemsPerSec, eta
|
||||
}
|
||||
|
||||
// ProgressStats bundles the derived progress metrics for a phase, ready to
|
||||
// drop into a readiness / health payload.
|
||||
type ProgressStats struct {
|
||||
Phase string `json:"phase"`
|
||||
Current int `json:"current"`
|
||||
Total int `json:"total"`
|
||||
Percent int `json:"percent"`
|
||||
ItemsPerSec float64 `json:"items_per_sec"`
|
||||
Elapsed string `json:"elapsed"`
|
||||
ETA string `json:"eta,omitempty"`
|
||||
}
|
||||
|
||||
// Stats computes the derived progress metrics for a phase from its start
|
||||
// time and counters — percent, throughput, human-formatted elapsed, and a
|
||||
// human-formatted ETA (omitted when the total is unknown).
|
||||
func Stats(phase string, start time.Time, current, total int) ProgressStats {
|
||||
rate, eta := ETA(start, current, total)
|
||||
ps := ProgressStats{
|
||||
Phase: phase,
|
||||
Current: current,
|
||||
Total: total,
|
||||
ItemsPerSec: rate,
|
||||
Elapsed: FormatElapsed(time.Since(start)),
|
||||
}
|
||||
if total > 0 {
|
||||
ps.Percent = int(float64(current) / float64(total) * 100)
|
||||
}
|
||||
if eta > 0 {
|
||||
ps.ETA = FormatElapsed(eta)
|
||||
}
|
||||
return ps
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatElapsed(t *testing.T) {
|
||||
cases := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{0, "0s"},
|
||||
{30 * time.Second, "30s"},
|
||||
{90 * time.Second, "1m 30s"},
|
||||
{3700 * time.Second, "1h 01m"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FormatElapsed(c.d); got != c.want {
|
||||
t.Errorf("FormatElapsed(%v) = %q, want %q", c.d, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestETAAndStats(t *testing.T) {
|
||||
start := time.Now().Add(-10 * time.Second)
|
||||
|
||||
rate, eta := ETA(start, 100, 200)
|
||||
if rate < 5 || rate > 20 { // ~10/s
|
||||
t.Errorf("rate = %v, want ~10/s", rate)
|
||||
}
|
||||
if eta <= 0 {
|
||||
t.Error("eta should be positive when total > current")
|
||||
}
|
||||
|
||||
// Unknown total → no eta.
|
||||
if _, eta2 := ETA(start, 100, 0); eta2 != 0 {
|
||||
t.Errorf("unknown total must yield zero eta, got %v", eta2)
|
||||
}
|
||||
|
||||
ps := Stats(string(PhaseParse), start, 100, 200)
|
||||
if ps.Percent != 50 {
|
||||
t.Errorf("percent = %d, want 50", ps.Percent)
|
||||
}
|
||||
if ps.ETA == "" {
|
||||
t.Error("Stats must include an ETA when total known")
|
||||
}
|
||||
if ps.Phase != "parsing" {
|
||||
t.Errorf("phase = %q", ps.Phase)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fmtCount renders n with thousands separators ("48,210"). Counters in the
|
||||
// step rows read as magnitudes, not digit strings; the separator makes a
|
||||
// six-digit symbol count legible at a glance.
|
||||
func fmtCount(n int64) string {
|
||||
s := strconv.FormatInt(n, 10)
|
||||
neg := false
|
||||
if len(s) > 0 && s[0] == '-' {
|
||||
neg = true
|
||||
s = s[1:]
|
||||
}
|
||||
if len(s) <= 3 {
|
||||
if neg {
|
||||
return "-" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
head := len(s) % 3
|
||||
out := make([]byte, 0, len(s)+len(s)/3+1)
|
||||
if neg {
|
||||
out = append(out, '-')
|
||||
}
|
||||
if head > 0 {
|
||||
out = append(out, s[:head]...)
|
||||
}
|
||||
for i := head; i < len(s); i += 3 {
|
||||
if len(out) > 0 && out[len(out)-1] != '-' {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, s[i:i+3]...)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// fmtDurationCompact renders a duration at the precision a progress line
|
||||
// wants: sub-10s with one decimal ("3.2s"), sub-minute in whole seconds,
|
||||
// then minute granularity. Sub-100ms rounds to "0.1s" floor-less so a fast
|
||||
// step still shows a real number.
|
||||
func fmtDurationCompact(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
ms := d.Milliseconds()
|
||||
if ms < 1 {
|
||||
ms = 1
|
||||
}
|
||||
return fmt.Sprintf("%dms", ms)
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
|
||||
case d < time.Hour:
|
||||
m := int(d / time.Minute)
|
||||
s := int((d % time.Minute) / time.Second)
|
||||
return fmt.Sprintf("%dm%02ds", m, s)
|
||||
default:
|
||||
h := int(d / time.Hour)
|
||||
m := int((d % time.Hour) / time.Minute)
|
||||
return fmt.Sprintf("%dh%02dm", h, m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFmtCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int64
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{5, "5"},
|
||||
{999, "999"},
|
||||
{1000, "1,000"},
|
||||
{48210, "48,210"},
|
||||
{612940, "612,940"},
|
||||
{1234567, "1,234,567"},
|
||||
{-1234, "-1,234"},
|
||||
{-12, "-12"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := fmtCount(c.in); got != c.want {
|
||||
t.Errorf("fmtCount(%d) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtDurationCompact(t *testing.T) {
|
||||
cases := []struct {
|
||||
in time.Duration
|
||||
want string
|
||||
}{
|
||||
{-time.Second, "1ms"},
|
||||
{0, "1ms"},
|
||||
{450 * time.Millisecond, "450ms"},
|
||||
{3200 * time.Millisecond, "3.2s"},
|
||||
{42 * time.Second, "42s"},
|
||||
{72 * time.Second, "1m12s"},
|
||||
{time.Hour + 5*time.Minute, "1h05m"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := fmtDurationCompact(c.in); got != c.want {
|
||||
t.Errorf("fmtDurationCompact(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// glyphSet is the small set of display glyphs that differ between a UTF-8
|
||||
// terminal and a legacy OEM / ASCII one: the success / failure markers, the
|
||||
// status dot, the mid-dot separator, and the box-drawing charset. Gortex
|
||||
// renders more box-drawing than a check/cross-only CLI (the rounded card
|
||||
// border), so the ASCII fallback has to cover the whole border too. The
|
||||
// live tracker adds the progress-bar cells, the pending marker, and the
|
||||
// busy / ready status dots to the same fallback contract.
|
||||
type glyphSet struct {
|
||||
OK string
|
||||
Fail string
|
||||
Dot string
|
||||
DotDim string
|
||||
Pending string
|
||||
Sep string
|
||||
Dash string
|
||||
Ellipsis string
|
||||
BarFull string
|
||||
BarEmpty string
|
||||
StatusBusy string
|
||||
StatusReady string
|
||||
Border lipgloss.Border
|
||||
}
|
||||
|
||||
var (
|
||||
unicodeGlyphs = glyphSet{
|
||||
OK: "✓",
|
||||
Fail: "✗",
|
||||
Dot: "●",
|
||||
DotDim: "●",
|
||||
Pending: "·",
|
||||
Sep: "·",
|
||||
Dash: "—",
|
||||
Ellipsis: "…",
|
||||
BarFull: "█",
|
||||
BarEmpty: "░",
|
||||
StatusBusy: "◐",
|
||||
StatusReady: "●",
|
||||
Border: lipgloss.RoundedBorder(),
|
||||
}
|
||||
asciiGlyphs = glyphSet{
|
||||
OK: "+",
|
||||
Fail: "x",
|
||||
Dot: "*",
|
||||
DotDim: "o",
|
||||
Pending: ".",
|
||||
Sep: "-",
|
||||
Dash: "-",
|
||||
Ellipsis: "...",
|
||||
BarFull: "#",
|
||||
BarEmpty: "-",
|
||||
StatusBusy: "o",
|
||||
StatusReady: "*",
|
||||
Border: lipgloss.ASCIIBorder(),
|
||||
}
|
||||
|
||||
brailleSpin = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
asciiSpin = []string{"|", "/", "-", "\\"}
|
||||
)
|
||||
|
||||
// activeGlyphs returns the glyph set appropriate to the current terminal:
|
||||
// UTF-8 box-drawing / check glyphs when it can render them, ASCII otherwise.
|
||||
// Resolved per call so a runtime override (or a test) takes effect immediately;
|
||||
// the cost is a couple of env reads plus, on Windows only, one cheap codepage
|
||||
// syscall.
|
||||
func activeGlyphs() glyphSet {
|
||||
if supportsUnicode() {
|
||||
return unicodeGlyphs
|
||||
}
|
||||
return asciiGlyphs
|
||||
}
|
||||
|
||||
// spinFrames returns the animated-spinner frame cycle. Braille frames need
|
||||
// font coverage that the check / box-drawing glyphs don't: legacy conhost
|
||||
// fonts (Consolas on a CP65001 console) render ✓ and █ but draw braille as
|
||||
// boxes, so on Windows the braille cycle is reserved for terminals that
|
||||
// declare themselves modern (Windows Terminal, ConEmu, an inherited TERM).
|
||||
// Everything else animates with the four-frame ASCII cycle.
|
||||
func spinFrames() []string {
|
||||
if !supportsUnicode() {
|
||||
return asciiSpin
|
||||
}
|
||||
if runtime.GOOS == "windows" && !windowsModernTerminal() {
|
||||
return asciiSpin
|
||||
}
|
||||
return brailleSpin
|
||||
}
|
||||
|
||||
// windowsModernTerminal reports whether the process runs under a Windows
|
||||
// terminal with full glyph coverage: Windows Terminal (WT_SESSION), ConEmu
|
||||
// (ConEmuANSI=ON), or an environment that carries a unix-style TERM (mintty,
|
||||
// msys, ssh sessions).
|
||||
func windowsModernTerminal() bool {
|
||||
if os.Getenv("WT_SESSION") != "" {
|
||||
return true
|
||||
}
|
||||
if os.Getenv("ConEmuANSI") == "ON" {
|
||||
return true
|
||||
}
|
||||
return os.Getenv("TERM") != ""
|
||||
}
|
||||
|
||||
// supportsUnicode reports whether the active terminal can render the UTF-8
|
||||
// box-drawing / check glyphs. Explicit env overrides win (GORTEX_ASCII opt-out,
|
||||
// GORTEX_UNICODE opt-in); a linux virtual console (TERM=linux, CP437-ish) and a
|
||||
// non-UTF-8 Windows console codepage both fall back to ASCII. Every other
|
||||
// terminal is assumed UTF-8-capable, the modern default.
|
||||
func supportsUnicode() bool {
|
||||
if envFlag("GORTEX_ASCII") {
|
||||
return false
|
||||
}
|
||||
if envFlag("GORTEX_UNICODE") {
|
||||
return true
|
||||
}
|
||||
if os.Getenv("TERM") == "linux" {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return windowsConsoleUTF8()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// envFlag reports whether the named env var is set to a truthy value.
|
||||
func envFlag(name string) bool {
|
||||
switch os.Getenv(name) {
|
||||
case "1", "true", "TRUE", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package progress
|
||||
|
||||
// windowsConsoleUTF8 is only consulted on Windows; supportsUnicode gates the
|
||||
// call behind a GOOS check, so on every other platform this stub merely keeps
|
||||
// the build green.
|
||||
func windowsConsoleUTF8() bool { return true }
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package progress
|
||||
|
||||
import "syscall"
|
||||
|
||||
// windowsConsoleUTF8 reports whether the active Windows console output codepage
|
||||
// is UTF-8 (65001). Legacy OEM codepages (437, 850, 866, …) — still the cmd.exe
|
||||
// default — cannot render the box-drawing / check glyphs, so the caller falls
|
||||
// back to ASCII. Uses the kernel32 GetConsoleOutputCP syscall directly to avoid
|
||||
// pulling in golang.org/x/sys.
|
||||
func windowsConsoleUTF8() bool {
|
||||
const cpUTF8 = 65001
|
||||
proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleOutputCP")
|
||||
cp, _, _ := proc.Call()
|
||||
return uint32(cp) == cpUTF8
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// The gortex mark is a 5×5 dot-matrix "G": an open ring with a tongue in the
|
||||
// middle row whose tip — the accent dot — always renders in brand green. The
|
||||
// live renderer lights the remaining dots progressively as work completes, so
|
||||
// the logo itself is the coarse progress gauge; banners render it fully lit.
|
||||
type gDot struct {
|
||||
r, c int
|
||||
accent bool
|
||||
}
|
||||
|
||||
// gDots lists the mark's dots in row-major order — the order they light up.
|
||||
var gDots = buildGDots()
|
||||
|
||||
func buildGDots() []gDot {
|
||||
const cells = 5
|
||||
mid := cells / 2
|
||||
var dots []gDot
|
||||
for r := 0; r < cells; r++ {
|
||||
for c := 0; c < cells; c++ {
|
||||
onTop := r == 0 && c >= 1 && c <= cells-2
|
||||
onBot := r == cells-1 && c >= 1 && c <= cells-2
|
||||
onLeft := c == 0 && r >= 1 && r <= cells-2
|
||||
onTongue := r == mid && c >= mid && c <= cells-1
|
||||
onTopRight := c == cells-1 && r == 1
|
||||
onBotRight := c == cells-1 && r == cells-2
|
||||
if onTop || onBot || onLeft || onTongue || onTopRight || onBotRight {
|
||||
dots = append(dots, gDot{r: r, c: c, accent: r == mid && c == cells-1})
|
||||
}
|
||||
}
|
||||
}
|
||||
return dots
|
||||
}
|
||||
|
||||
// litDotCount is the number of dots that participate in progressive lighting
|
||||
// (every dot except the always-lit accent tip).
|
||||
func litDotCount() int { return len(gDots) - 1 }
|
||||
|
||||
// logoLines renders the mark as 5 rows. frac in [0,1] lights that fraction of
|
||||
// the dots bottom-up in row-major order; frac < 0 selects the indeterminate
|
||||
// marquee — a short lit window orbiting the mark at the given tick. glint, when
|
||||
// >= 0, additionally paints dot (glint % len) in accent green: the traveling
|
||||
// highlight used by animated banners over a fully lit mark.
|
||||
func logoLines(lit, dim, accent lipgloss.Style, g glyphSet, frac float64, tick, glint int) [5]string {
|
||||
const cells = 5
|
||||
byPos := make(map[[2]int]int, len(gDots))
|
||||
for i, d := range gDots {
|
||||
byPos[[2]int{d.r, d.c}] = i
|
||||
}
|
||||
|
||||
litN := -1
|
||||
if frac >= 0 {
|
||||
if frac > 1 {
|
||||
frac = 1
|
||||
}
|
||||
litN = int(frac*float64(litDotCount()) + 0.5)
|
||||
}
|
||||
|
||||
// Non-accent ordinal per dot index, for both fill and marquee windows.
|
||||
ordinal := make([]int, len(gDots))
|
||||
ord := 0
|
||||
for i, d := range gDots {
|
||||
if d.accent {
|
||||
ordinal[i] = -1
|
||||
continue
|
||||
}
|
||||
ordinal[i] = ord
|
||||
ord++
|
||||
}
|
||||
|
||||
const marqueeWindow = 4
|
||||
var lines [5]string
|
||||
for r := 0; r < cells; r++ {
|
||||
var row strings.Builder
|
||||
for c := 0; c < cells; c++ {
|
||||
if c > 0 {
|
||||
row.WriteString(" ")
|
||||
}
|
||||
idx, isDot := byPos[[2]int{r, c}]
|
||||
if !isDot {
|
||||
row.WriteString(" ")
|
||||
continue
|
||||
}
|
||||
d := gDots[idx]
|
||||
switch {
|
||||
case d.accent:
|
||||
row.WriteString(accent.Render(g.Dot))
|
||||
case glint >= 0 && idx == glint%len(gDots):
|
||||
row.WriteString(accent.Render(g.Dot))
|
||||
case litN >= 0 && ordinal[idx] < litN:
|
||||
row.WriteString(lit.Render(g.Dot))
|
||||
case litN < 0 && marqueeHit(ordinal[idx], tick, marqueeWindow):
|
||||
row.WriteString(lit.Render(g.Dot))
|
||||
default:
|
||||
row.WriteString(dim.Render(g.DotDim))
|
||||
}
|
||||
}
|
||||
lines[r] = row.String()
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// marqueeHit reports whether the dot with the given non-accent ordinal falls
|
||||
// inside the lit window at this tick.
|
||||
func marqueeHit(ord, tick, window int) bool {
|
||||
n := litDotCount()
|
||||
if ord < 0 || n == 0 {
|
||||
return false
|
||||
}
|
||||
start := tick % n
|
||||
for i := 0; i < window; i++ {
|
||||
if (start+i)%n == ord {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MeshLogo renders one static frame of the gortex mark, fully lit, styled
|
||||
// with the package palette. A positive tick adds a traveling green glint —
|
||||
// animated banners (the wizard dashboard) pass a rising tick; static banners
|
||||
// pass 0 for the pure mark.
|
||||
func MeshLogo(tick int) string {
|
||||
glint := -1
|
||||
if tick > 0 {
|
||||
glint = tick
|
||||
}
|
||||
lines := logoLines(stylePerim, styleInner, styleAccent, activeGlyphs(), 1, tick, glint)
|
||||
return strings.Join(lines[:], "\n")
|
||||
}
|
||||
|
||||
// MeshFrame returns the gortex mark with label (bold) and sub (dim) beside
|
||||
// it. Used by watch loops or custom views that want the brand block without
|
||||
// owning a live tracker.
|
||||
func MeshFrame(tick int, label, sub string) string {
|
||||
mesh := MeshLogo(tick)
|
||||
if label == "" && sub == "" {
|
||||
return mesh + "\n"
|
||||
}
|
||||
right := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
"",
|
||||
styleLabel.Render(label),
|
||||
"",
|
||||
styleSub.Render(sub),
|
||||
"",
|
||||
)
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, mesh, " ", right) + "\n"
|
||||
}
|
||||
|
||||
// MeshLogoLines returns the number of vertical rows the mark occupies.
|
||||
// Exported so wizard / dashboard layouts can reserve space without re-counting
|
||||
// the constant.
|
||||
func MeshLogoLines() int { return 5 }
|
||||
|
||||
// MeshLogoWidth returns the rendered visual width of the mark in cells.
|
||||
// 5 cells × "● " spacing = 9 chars.
|
||||
func MeshLogoWidth() int { return 9 }
|
||||
@@ -0,0 +1,110 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// TestGMarkShape pins the brand mark's geometry: 14 dots forming the
|
||||
// dot-matrix G, accent at the tongue tip.
|
||||
func TestGMarkShape(t *testing.T) {
|
||||
if len(gDots) != 14 {
|
||||
t.Fatalf("expected 14 dots in the mark, got %d", len(gDots))
|
||||
}
|
||||
want := map[[2]int]bool{
|
||||
{0, 1}: true, {0, 2}: true, {0, 3}: true,
|
||||
{1, 0}: true, {1, 4}: true,
|
||||
{2, 0}: true, {2, 2}: true, {2, 3}: true, {2, 4}: true,
|
||||
{3, 0}: true, {3, 4}: true,
|
||||
{4, 1}: true, {4, 2}: true, {4, 3}: true,
|
||||
}
|
||||
accents := 0
|
||||
for _, d := range gDots {
|
||||
if !want[[2]int{d.r, d.c}] {
|
||||
t.Errorf("unexpected dot at (%d,%d)", d.r, d.c)
|
||||
}
|
||||
if d.accent {
|
||||
accents++
|
||||
if d.r != 2 || d.c != 4 {
|
||||
t.Errorf("accent dot must be the tongue tip (2,4), got (%d,%d)", d.r, d.c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if accents != 1 {
|
||||
t.Errorf("expected exactly one accent dot, got %d", accents)
|
||||
}
|
||||
}
|
||||
|
||||
// distinctGlyphs makes lit / dim / accent dots distinguishable without color.
|
||||
var distinctGlyphs = glyphSet{Dot: "X", DotDim: "."}
|
||||
|
||||
func renderLogoGlyphs(frac float64, tick, glint int) string {
|
||||
plain := lipgloss.NewStyle()
|
||||
lines := logoLines(plain, plain, plain, distinctGlyphs, frac, tick, glint)
|
||||
return strings.Join(lines[:], "\n")
|
||||
}
|
||||
|
||||
func TestLogoProgressiveLighting(t *testing.T) {
|
||||
// frac=0: only the accent tip is bright.
|
||||
if got := strings.Count(renderLogoGlyphs(0, 0, -1), "X"); got != 1 {
|
||||
t.Errorf("frac=0 must light only the accent tip, got %d bright dots", got)
|
||||
}
|
||||
// frac=1: every dot is bright.
|
||||
if got := strings.Count(renderLogoGlyphs(1, 0, -1), "X"); got != 14 {
|
||||
t.Errorf("frac=1 must light all 14 dots, got %d", got)
|
||||
}
|
||||
// Lighting is monotonic in frac.
|
||||
prev := 0
|
||||
for _, frac := range []float64{0, 0.25, 0.5, 0.75, 1} {
|
||||
n := strings.Count(renderLogoGlyphs(frac, 0, -1), "X")
|
||||
if n < prev {
|
||||
t.Errorf("lighting regressed at frac=%.2f: %d < %d", frac, n, prev)
|
||||
}
|
||||
prev = n
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoMarqueeWindow(t *testing.T) {
|
||||
// Indeterminate mode lights the accent tip plus a 4-dot window.
|
||||
if got := strings.Count(renderLogoGlyphs(-1, 0, -1), "X"); got != 5 {
|
||||
t.Errorf("marquee tick 0 must light tip + 4-dot window, got %d", got)
|
||||
}
|
||||
// The window moves with the tick.
|
||||
if renderLogoGlyphs(-1, 0, -1) == renderLogoGlyphs(-1, 3, -1) {
|
||||
t.Error("marquee frames must differ across ticks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoLinesWidthStable(t *testing.T) {
|
||||
plain := lipgloss.NewStyle()
|
||||
for _, frac := range []float64{-1, 0, 0.5, 1} {
|
||||
lines := logoLines(plain, plain, plain, unicodeGlyphs, frac, 2, -1)
|
||||
for i, ln := range lines {
|
||||
if w := ansi.StringWidth(ln); w != MeshLogoWidth() {
|
||||
t.Errorf("frac=%.1f line %d width = %d, want %d (%q)", frac, i, w, MeshLogoWidth(), ln)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshLogoCompat(t *testing.T) {
|
||||
t.Setenv("GORTEX_ASCII", "")
|
||||
t.Setenv("GORTEX_UNICODE", "1")
|
||||
logo := MeshLogo(0)
|
||||
lines := strings.Split(logo, "\n")
|
||||
if len(lines) != MeshLogoLines() {
|
||||
t.Fatalf("MeshLogo renders %d lines, want %d", len(lines), MeshLogoLines())
|
||||
}
|
||||
for i, ln := range lines {
|
||||
if w := ansi.StringWidth(ln); w != MeshLogoWidth() {
|
||||
t.Errorf("line %d width = %d, want %d", i, w, MeshLogoWidth())
|
||||
}
|
||||
}
|
||||
// Static banner logo is fully lit: all 14 dots present.
|
||||
if got := strings.Count(ansi.Strip(logo), "●"); got != 14 {
|
||||
t.Errorf("static mark must show all 14 dots, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// IsTTY reports whether w is backed by a terminal file descriptor.
|
||||
func IsTTY(w io.Writer) bool {
|
||||
f, ok := w.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
func envDisablesColor() bool {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return true
|
||||
}
|
||||
if t := os.Getenv("TERM"); t == "dumb" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// animationAllowed decides whether w can host the animated renderer. The
|
||||
// contract matches the --no-progress help text: NO_COLOR, TERM=dumb, a
|
||||
// non-TTY writer, and CI all force plain output. On Windows it additionally
|
||||
// requires the console to accept VT processing (legacy conhost without VT
|
||||
// gets plain text rather than raw escape soup). GORTEX_FORCE_ANIMATION=1
|
||||
// overrides everything — it exists for demos and PTY-less frame capture.
|
||||
func animationAllowed(w io.Writer) bool {
|
||||
if envFlag("GORTEX_FORCE_ANIMATION") {
|
||||
return true
|
||||
}
|
||||
if !IsTTY(w) {
|
||||
return false
|
||||
}
|
||||
if envDisablesColor() {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("CI") != "" {
|
||||
return false
|
||||
}
|
||||
return enableVT(w)
|
||||
}
|
||||
|
||||
// cursorHiddenOnStderr tracks whether an animated tracker writing to the
|
||||
// process stderr currently has the cursor hidden. RestoreTerminal uses it to
|
||||
// undo the hide from exit paths that bypass a tracker's own finish (fatal
|
||||
// errors, panics unwinding main).
|
||||
var cursorHiddenOnStderr atomic.Bool
|
||||
|
||||
// RestoreTerminal re-shows the cursor and resets SGR state on stderr if an
|
||||
// animated tracker left the cursor hidden. Safe to call unconditionally and
|
||||
// repeatedly; a no-op when nothing needs restoring. Wire it into every
|
||||
// process exit path that can interrupt a live animation.
|
||||
func RestoreTerminal() {
|
||||
if cursorHiddenOnStderr.CompareAndSwap(true, false) {
|
||||
_, _ = os.Stderr.WriteString(ansiShowCursor + ansiReset)
|
||||
}
|
||||
}
|
||||
|
||||
func markCursorHidden(w io.Writer) { setCursorFlag(w, true) }
|
||||
func markCursorRestored(w io.Writer) { setCursorFlag(w, false) }
|
||||
|
||||
func setCursorFlag(w io.Writer, hidden bool) {
|
||||
if f, ok := w.(*os.File); ok && f == os.Stderr {
|
||||
cursorHiddenOnStderr.Store(hidden)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvDisablesColor(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "")
|
||||
t.Setenv("TERM", "xterm-256color")
|
||||
if envDisablesColor() {
|
||||
t.Error("plain xterm env must not disable color")
|
||||
}
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
if !envDisablesColor() {
|
||||
t.Error("NO_COLOR must disable color")
|
||||
}
|
||||
t.Setenv("NO_COLOR", "")
|
||||
t.Setenv("TERM", "dumb")
|
||||
if !envDisablesColor() {
|
||||
t.Error("TERM=dumb must disable color")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimationAllowedGates(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
t.Setenv("GORTEX_FORCE_ANIMATION", "")
|
||||
if animationAllowed(&buf) {
|
||||
t.Error("a non-TTY writer must not animate")
|
||||
}
|
||||
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
|
||||
if !animationAllowed(&buf) {
|
||||
t.Error("GORTEX_FORCE_ANIMATION must override TTY detection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTerminalIdempotent(t *testing.T) {
|
||||
cursorHiddenOnStderr.Store(true)
|
||||
RestoreTerminal()
|
||||
if cursorHiddenOnStderr.Load() {
|
||||
t.Error("RestoreTerminal must clear the hidden-cursor flag")
|
||||
}
|
||||
RestoreTerminal() // second call is a no-op
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package progress provides a small reporter interface for long-running
|
||||
// operations (indexing, embedding, enrichment) to emit incremental updates.
|
||||
// The MCP server uses this to convert indexer stage events into
|
||||
// `notifications/progress` messages when the client supplies a progressToken.
|
||||
package progress
|
||||
|
||||
import "context"
|
||||
|
||||
// Reporter receives progress updates. Implementations must be safe for
|
||||
// concurrent use and must never panic — callers emit updates from hot paths.
|
||||
type Reporter interface {
|
||||
// Report a progress tick. stage is a short human-readable label.
|
||||
// current and total are within-stage counters; total may be 0 when
|
||||
// the total work is unknown (e.g., before a walk completes).
|
||||
Report(stage string, current, total int)
|
||||
}
|
||||
|
||||
// Nop is a Reporter that discards all updates. Returned by FromContext when
|
||||
// no reporter is attached, so callers can unconditionally invoke Report.
|
||||
type Nop struct{}
|
||||
|
||||
// Report does nothing.
|
||||
func (Nop) Report(string, int, int) {}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithReporter returns a context carrying the given reporter. Passing a nil
|
||||
// reporter returns ctx unchanged so FromContext still yields Nop.
|
||||
func WithReporter(ctx context.Context, r Reporter) context.Context {
|
||||
if r == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, ctxKey{}, r)
|
||||
}
|
||||
|
||||
// FromContext returns the reporter in ctx, or Nop if none is attached.
|
||||
// Never returns nil — callers may call Report directly on the result.
|
||||
func FromContext(ctx context.Context) Reporter {
|
||||
if ctx == nil {
|
||||
return Nop{}
|
||||
}
|
||||
if r, ok := ctx.Value(ctxKey{}).(Reporter); ok && r != nil {
|
||||
return r
|
||||
}
|
||||
return Nop{}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNop_NoPanic(t *testing.T) {
|
||||
Nop{}.Report("anything", 0, 0)
|
||||
Nop{}.Report("stage", 1, 100)
|
||||
}
|
||||
|
||||
func TestFromContext_NilCtx_ReturnsNop(t *testing.T) {
|
||||
r := FromContext(context.TODO())
|
||||
if r == nil {
|
||||
t.Fatal("FromContext must never return nil")
|
||||
}
|
||||
r.Report("x", 0, 0) // must not panic
|
||||
}
|
||||
|
||||
func TestFromContext_NoReporter_ReturnsNop(t *testing.T) {
|
||||
r := FromContext(context.Background())
|
||||
if _, ok := r.(Nop); !ok {
|
||||
t.Errorf("expected Nop when no reporter attached, got %T", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithReporter_Roundtrip(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
ctx := WithReporter(context.Background(), rec)
|
||||
|
||||
FromContext(ctx).Report("parse", 3, 10)
|
||||
|
||||
if len(rec.events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(rec.events))
|
||||
}
|
||||
ev := rec.events[0]
|
||||
if ev.stage != "parse" || ev.current != 3 || ev.total != 10 {
|
||||
t.Errorf("unexpected event: %+v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithReporter_NilReporter_NoOp(t *testing.T) {
|
||||
ctx := WithReporter(context.Background(), nil)
|
||||
r := FromContext(ctx)
|
||||
if _, ok := r.(Nop); !ok {
|
||||
t.Errorf("nil reporter must not leak through context, got %T", r)
|
||||
}
|
||||
}
|
||||
|
||||
type recEvent struct {
|
||||
stage string
|
||||
current, total int
|
||||
}
|
||||
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
events []recEvent
|
||||
}
|
||||
|
||||
func (r *recorder) Report(stage string, current, total int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.events = append(r.events, recEvent{stage, current, total})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Spinner is the single-operation face of the Tracker, kept for the many
|
||||
// call sites that want "animate this one label, then ✓ or ✗". It is a thin
|
||||
// facade: on a capable terminal the Tracker renders a live compact header
|
||||
// (spinner, title, status, elapsed) and turns Reporter stage transitions
|
||||
// into a checklist of step rows; elsewhere it degrades to clean line output.
|
||||
// Implements Reporter so it can be installed via WithReporter.
|
||||
type Spinner struct {
|
||||
t *Tracker
|
||||
}
|
||||
|
||||
// NewSpinner constructs a Spinner bound to w. When w isn't a TTY (or NO_COLOR
|
||||
// / TERM=dumb / CI is set), the spinner is created in plain-text mode.
|
||||
func NewSpinner(w io.Writer) *Spinner {
|
||||
return &Spinner{t: NewTracker(w)}
|
||||
}
|
||||
|
||||
// Disable forces the spinner into plain-text mode. Effective before Start.
|
||||
func (s *Spinner) Disable() { s.t.disable() }
|
||||
|
||||
// Enabled reports whether the spinner is animating.
|
||||
func (s *Spinner) Enabled() bool { return s.t.Animated() }
|
||||
|
||||
// Start begins animating with the given label.
|
||||
func (s *Spinner) Start(label string) { s.t.Start(label) }
|
||||
|
||||
// Set updates the label and sub-status mid-animation. Either may be empty to
|
||||
// leave the existing value.
|
||||
func (s *Spinner) Set(label, sub string) {
|
||||
s.t.SetTitle(label)
|
||||
s.t.SetStatus(sub)
|
||||
}
|
||||
|
||||
// Report implements Reporter. Each distinct stage becomes its own live step
|
||||
// row (with counters and, when the total is known, a progress bar); in plain
|
||||
// mode stages print as start / finish line pairs with a slow heartbeat.
|
||||
func (s *Spinner) Report(stage string, current, total int) {
|
||||
s.t.Report(stage, current, total)
|
||||
}
|
||||
|
||||
// Done stops the spinner and replaces the frame with a green ✓ summary.
|
||||
func (s *Spinner) Done() { s.t.Done("", "") }
|
||||
|
||||
// Fail stops the spinner and replaces the frame with a red ✗ summary.
|
||||
func (s *Spinner) Fail(err error) { s.t.Fail(err) }
|
||||
|
||||
// Tracker exposes the underlying tracker for call sites that outgrow the
|
||||
// single-label surface (explicit steps, log lines above the animation).
|
||||
func (s *Spinner) Tracker() *Tracker { return s.t }
|
||||
|
||||
// Multi fans out reporter ticks to all of rs. Nil entries are skipped.
|
||||
func Multi(rs ...Reporter) Reporter {
|
||||
out := make([]Reporter, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
switch len(out) {
|
||||
case 0:
|
||||
return Nop{}
|
||||
case 1:
|
||||
return out[0]
|
||||
default:
|
||||
return multiReporter(out)
|
||||
}
|
||||
}
|
||||
|
||||
type multiReporter []Reporter
|
||||
|
||||
func (m multiReporter) Report(stage string, current, total int) {
|
||||
for _, r := range m {
|
||||
r.Report(stage, current, total)
|
||||
}
|
||||
}
|
||||
|
||||
// Run animates a spinner around fn. The context passed to fn carries the
|
||||
// spinner as a Reporter, so any progress.FromContext(ctx).Report(…) inside fn
|
||||
// drives the live step rows. The spinner is finished (✓ or ✗) before Run
|
||||
// returns.
|
||||
func Run(ctx context.Context, w io.Writer, label string, fn func(context.Context) error) error {
|
||||
sp := NewSpinner(w)
|
||||
return runWith(ctx, sp, label, fn)
|
||||
}
|
||||
|
||||
// RunDisabled is Run with the spinner forced into plain-text mode.
|
||||
func RunDisabled(ctx context.Context, w io.Writer, label string, fn func(context.Context) error) error {
|
||||
sp := NewSpinner(w)
|
||||
sp.Disable()
|
||||
return runWith(ctx, sp, label, fn)
|
||||
}
|
||||
|
||||
func runWith(ctx context.Context, sp *Spinner, label string, fn func(context.Context) error) error {
|
||||
sp.Start(label)
|
||||
ctx = WithReporter(ctx, sp)
|
||||
err := fn(ctx)
|
||||
if err != nil {
|
||||
sp.Fail(err)
|
||||
} else {
|
||||
sp.Done()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpinnerDisabledModePrintsPlainText(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sp := NewSpinner(&buf)
|
||||
sp.Disable()
|
||||
|
||||
sp.Start("Indexing repository")
|
||||
sp.Report("walking files", 0, 0)
|
||||
sp.Report("walking files", 50, 100) // same stage, must not re-emit
|
||||
sp.Report("parsing", 0, 0) // new stage: finishes the previous, starts its own
|
||||
sp.Done()
|
||||
|
||||
out := buf.String()
|
||||
wants := []string{
|
||||
"Indexing repository",
|
||||
"walking files ...", // stage start
|
||||
"✓ walking files · 50", // stage finish with final counter
|
||||
"parsing ...", // next stage start
|
||||
"✓ parsing", // finished by Done
|
||||
"✓ Indexing repository", // overall summary
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("output missing %q\n--- got ---\n%s", w, out)
|
||||
}
|
||||
}
|
||||
// Each stage prints exactly twice — one start line, one finish line —
|
||||
// regardless of how many progress ticks arrive in between.
|
||||
if got := strings.Count(out, "walking files"); got != 2 {
|
||||
t.Errorf("expected stage 'walking files' to print exactly twice (start+finish), got %d\n--- got ---\n%s", got, out)
|
||||
}
|
||||
if idx := strings.Index(out, "parsing ..."); idx < strings.Index(out, "✓ walking files") {
|
||||
t.Errorf("stage finish line must precede the next stage start\n--- got ---\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpinnerDisabledFailPrintsErrorLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sp := NewSpinner(&buf)
|
||||
sp.Disable()
|
||||
|
||||
sp.Start("Stamping blame")
|
||||
sp.Fail(errors.New("boom"))
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "✗ Stamping blame") {
|
||||
t.Errorf("expected ✗ summary, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "boom") {
|
||||
t.Errorf("expected error message in output, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpinnerDoneIsIdempotent(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sp := NewSpinner(&buf)
|
||||
sp.Disable()
|
||||
|
||||
sp.Start("Indexing")
|
||||
sp.Done()
|
||||
sp.Done() // must not double-print or panic
|
||||
sp.Fail(errors.New("late")) // must be a no-op after Done
|
||||
|
||||
if got := strings.Count(buf.String(), "✓"); got != 1 {
|
||||
t.Errorf("expected exactly one ✓, got %d in:\n%s", got, buf.String())
|
||||
}
|
||||
if strings.Contains(buf.String(), "✗") {
|
||||
t.Errorf("expected no ✗ after successful Done, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFansOutAndSkipsNil(t *testing.T) {
|
||||
a := &countingReporter{}
|
||||
b := &countingReporter{}
|
||||
r := Multi(a, nil, b)
|
||||
|
||||
r.Report("walk", 1, 10)
|
||||
r.Report("parse", 0, 0)
|
||||
|
||||
if a.calls != 2 || b.calls != 2 {
|
||||
t.Errorf("expected 2 calls each, got a=%d b=%d", a.calls, b.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiCollapsesToSingle(t *testing.T) {
|
||||
a := &countingReporter{}
|
||||
r := Multi(nil, a, nil)
|
||||
if r != a {
|
||||
t.Errorf("expected Multi with one non-nil to return that reporter directly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiAllNilReturnsNop(t *testing.T) {
|
||||
r := Multi(nil, nil)
|
||||
if _, ok := r.(Nop); !ok {
|
||||
t.Errorf("expected Nop when all inputs nil, got %T", r)
|
||||
}
|
||||
}
|
||||
|
||||
type countingReporter struct{ calls int }
|
||||
|
||||
func (c *countingReporter) Report(string, int, int) { c.calls++ }
|
||||
|
||||
// TestASCIIGlyphFallbackOnOEMCodepage proves the F8 contract: a terminal that
|
||||
// cannot render UTF-8 (a legacy OEM codepage, a linux virtual console, or an
|
||||
// explicit GORTEX_ASCII) gets an ASCII glyph set for the spinner finish
|
||||
// markers AND the box-drawing border — not just the check/cross glyphs.
|
||||
func TestASCIIGlyphFallbackOnOEMCodepage(t *testing.T) {
|
||||
t.Run("ascii_override_selects_ascii_set", func(t *testing.T) {
|
||||
t.Setenv("GORTEX_UNICODE", "")
|
||||
t.Setenv("GORTEX_ASCII", "1")
|
||||
if supportsUnicode() {
|
||||
t.Fatal("GORTEX_ASCII=1 must disable unicode")
|
||||
}
|
||||
g := activeGlyphs()
|
||||
if g.OK != "+" || g.Fail != "x" {
|
||||
t.Errorf("ascii markers = %q/%q, want +/x", g.OK, g.Fail)
|
||||
}
|
||||
// The border charset is ASCII too — the beat-codegraph axis.
|
||||
if g.Border.TopLeft != "+" {
|
||||
t.Errorf("ascii border TopLeft = %q, want +", g.Border.TopLeft)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unicode_override_wins_over_term_linux", func(t *testing.T) {
|
||||
t.Setenv("GORTEX_ASCII", "")
|
||||
t.Setenv("TERM", "linux")
|
||||
t.Setenv("GORTEX_UNICODE", "1")
|
||||
if !supportsUnicode() {
|
||||
t.Fatal("GORTEX_UNICODE=1 must enable unicode even on TERM=linux")
|
||||
}
|
||||
if activeGlyphs().OK != "✓" {
|
||||
t.Error("unicode set must use ✓")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("term_linux_falls_back_to_ascii", func(t *testing.T) {
|
||||
t.Setenv("GORTEX_ASCII", "")
|
||||
t.Setenv("GORTEX_UNICODE", "")
|
||||
t.Setenv("TERM", "linux")
|
||||
if supportsUnicode() {
|
||||
t.Fatal("TERM=linux must fall back to ASCII")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spinner_done_uses_ascii_marker", func(t *testing.T) {
|
||||
t.Setenv("GORTEX_UNICODE", "")
|
||||
t.Setenv("GORTEX_ASCII", "1")
|
||||
var buf bytes.Buffer
|
||||
sp := NewSpinner(&buf) // not a TTY → disabled → direct Fprintf path
|
||||
sp.Start("Indexing")
|
||||
sp.Done()
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "+ Indexing") {
|
||||
t.Errorf("done line should use the ASCII marker; got %q", out)
|
||||
}
|
||||
if strings.Contains(out, "✓") {
|
||||
t.Errorf("done line must not contain the unicode check; got %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spinner_fail_uses_ascii_cross", func(t *testing.T) {
|
||||
t.Setenv("GORTEX_UNICODE", "")
|
||||
t.Setenv("GORTEX_ASCII", "1")
|
||||
var buf bytes.Buffer
|
||||
sp := NewSpinner(&buf)
|
||||
sp.Start("Indexing")
|
||||
sp.Fail(errors.New("boom"))
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "x Indexing") {
|
||||
t.Errorf("fail line should use the ASCII cross; got %q", out)
|
||||
}
|
||||
if strings.Contains(out, "✗") {
|
||||
t.Errorf("fail line must not contain the unicode cross; got %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card_border_is_ascii", func(t *testing.T) {
|
||||
t.Setenv("GORTEX_UNICODE", "")
|
||||
t.Setenv("GORTEX_ASCII", "1")
|
||||
card := Card("", "hello")
|
||||
if !strings.Contains(card, "+") {
|
||||
t.Errorf("ascii card must use + corners; got %q", card)
|
||||
}
|
||||
if strings.ContainsAny(card, "╭╮╰╯─│") {
|
||||
t.Errorf("ascii card must not use rounded box-drawing; got %q", card)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/muesli/termenv"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// ANSI control sequences used by the live renderer. Private-mode toggles that
|
||||
// a terminal doesn't implement (the ?2026 synchronized-update pair) are
|
||||
// ignored by spec, so they are safe to emit unconditionally.
|
||||
const (
|
||||
ansiHideCursor = "\x1b[?25l"
|
||||
ansiShowCursor = "\x1b[?25h"
|
||||
ansiClearEOL = "\x1b[K"
|
||||
ansiClearBelow = "\x1b[J"
|
||||
ansiSyncStart = "\x1b[?2026h"
|
||||
ansiSyncEnd = "\x1b[?2026l"
|
||||
ansiReset = "\x1b[0m"
|
||||
)
|
||||
|
||||
// ansiUp moves the cursor n lines up (column unchanged). n <= 0 is a no-op.
|
||||
func ansiUp(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("\x1b[%dA", n)
|
||||
}
|
||||
|
||||
// colorProfileFor resolves the color depth for the animated renderer bound to
|
||||
// w. termenv's per-platform detection covers COLORTERM / TERM on unix and the
|
||||
// console-API probes on Windows; a writer that isn't a real terminal (frame
|
||||
// capture under GORTEX_FORCE_ANIMATION) degrades to plain-byte styles unless
|
||||
// the caller overrides the profile explicitly.
|
||||
func colorProfileFor(w io.Writer) termenv.Profile {
|
||||
return termenv.NewOutput(w).ColorProfile()
|
||||
}
|
||||
|
||||
// termSize returns the terminal dimensions behind w, falling back to a
|
||||
// conservative 80×24 when w has no descriptor or the ioctl fails (captures,
|
||||
// tests). Queried per frame so a live resize re-clamps the next repaint
|
||||
// without any platform-specific resize signal.
|
||||
func termSize(w io.Writer) (width, height int) {
|
||||
width, height = 80, 24
|
||||
f, ok := w.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return width, height
|
||||
}
|
||||
cw, ch, err := term.GetSize(int(f.Fd()))
|
||||
if err != nil || cw <= 0 || ch <= 0 {
|
||||
return width, height
|
||||
}
|
||||
return cw, ch
|
||||
}
|
||||
|
||||
// visibleWidth measures the printable cell width of a styled line,
|
||||
// ANSI-aware.
|
||||
func visibleWidth(s string) int { return ansi.StringWidth(s) }
|
||||
|
||||
// clampLine hard-truncates a styled line to width cells, ANSI-aware, so a
|
||||
// frame line can never soft-wrap. Soft wrap is fatal to a repaint renderer:
|
||||
// one wrapped line desyncs the cursor-up arithmetic and every later frame
|
||||
// smears. The ellipsis tail marks the cut.
|
||||
func clampLine(s string, width int, ellipsis string) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
if ansi.StringWidth(s) <= width {
|
||||
return s
|
||||
}
|
||||
return ansi.Truncate(s, width, ellipsis)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package progress
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Cozy palette — gortex mark. One source of truth for brand color across the
|
||||
// binary; the tracker binds these to its own writer-scoped renderer, while
|
||||
// the static helpers below use the package-global styles.
|
||||
var (
|
||||
colPerim = lipgloss.Color("#F0F0F0")
|
||||
colInner = lipgloss.Color("#46464E")
|
||||
colAccent = lipgloss.Color("#5FD67A")
|
||||
colAccentDim = lipgloss.Color("#4FB268")
|
||||
colFg = lipgloss.Color("#F4F3EF")
|
||||
colFgDim = lipgloss.Color("#969699")
|
||||
colErr = lipgloss.Color("#F06E64")
|
||||
|
||||
stylePerim = lipgloss.NewStyle().Foreground(colPerim)
|
||||
styleInner = lipgloss.NewStyle().Foreground(colInner)
|
||||
styleAccent = lipgloss.NewStyle().Foreground(colAccent)
|
||||
styleLabel = lipgloss.NewStyle().Bold(true).Foreground(colFg)
|
||||
styleSub = lipgloss.NewStyle().Foreground(colFgDim)
|
||||
styleOK = lipgloss.NewStyle().Foreground(colAccent)
|
||||
styleX = lipgloss.NewStyle().Foreground(colErr)
|
||||
)
|
||||
|
||||
// Exported palette — kept as a thin re-export layer over the package-private
|
||||
// `col*` constants so the rest of the binary (cmd/gortex, internal/tui) can
|
||||
// share one source of truth for brand colour without each callsite hard-coding
|
||||
// hex literals.
|
||||
//
|
||||
// Anything inside internal/progress can keep using the short private names;
|
||||
// callers outside the package should reach for these exported variants.
|
||||
var (
|
||||
ColorAccent = colAccent
|
||||
ColorPerim = colPerim
|
||||
ColorInner = colInner
|
||||
ColorFg = colFg
|
||||
ColorFgDim = colFgDim
|
||||
ColorErr = colErr
|
||||
ColorWarn = colWarn
|
||||
ColorMuted = colMuted
|
||||
ColorBorder = colBorder
|
||||
ColorInfoSoft = colInfoSoft
|
||||
)
|
||||
|
||||
// Exported styles. Lipgloss styles are immutable on chaining (every setter
|
||||
// returns a new copy) so exposing the package-level globals is safe — callers
|
||||
// cannot mutate them in place.
|
||||
var (
|
||||
StyleAccent = styleAccent
|
||||
StyleLabel = styleLabel
|
||||
StyleSub = styleSub
|
||||
StyleOK = styleOK
|
||||
StyleErr = styleX
|
||||
StyleHeading = styleHeading
|
||||
StyleCount = styleCount
|
||||
StyleKey = styleKey
|
||||
StyleVal = styleVal
|
||||
StyleHint = styleHint
|
||||
StyleStep = styleStep
|
||||
StyleStrong = styleStrong
|
||||
StyleBox = styleBox
|
||||
)
|
||||
|
||||
// PaletteFg / PaletteAccent / PaletteErr expose the resolved lipgloss colors
|
||||
// for callers that need to apply them to a freshly-built style (rather than
|
||||
// re-using one of the pre-composed styles above). Returned values are
|
||||
// lipgloss.Color, ready to feed into any lipgloss.NewStyle().Foreground call.
|
||||
func PaletteFg() lipgloss.Color { return colFg }
|
||||
func PaletteAccent() lipgloss.Color { return colAccent }
|
||||
func PaletteErr() lipgloss.Color { return colErr }
|
||||
@@ -0,0 +1,102 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimingReporter records the first-seen timestamp of each stage and,
|
||||
// when printed, emits a per-stage duration breakdown. Shows where
|
||||
// wall-clock time is being spent during a full index pass.
|
||||
//
|
||||
// Stage transitions are detected by the reporter seeing a *new* stage
|
||||
// label arrive — subsequent ticks for the same stage (progress updates
|
||||
// like "parsing 3000/5000") don't create a new entry. This matches the
|
||||
// indexer's usage where it calls Report once at stage entry and then
|
||||
// again with counter updates.
|
||||
type TimingReporter struct {
|
||||
mu sync.Mutex
|
||||
start time.Time
|
||||
stages []stageEntry
|
||||
seen map[string]int // stage → index in stages (also suppresses duplicates)
|
||||
}
|
||||
|
||||
type stageEntry struct {
|
||||
name string
|
||||
seen time.Time
|
||||
ticks int
|
||||
}
|
||||
|
||||
// NewTimingReporter returns a reporter with its clock anchored at now.
|
||||
func NewTimingReporter() *TimingReporter {
|
||||
return &TimingReporter{
|
||||
start: time.Now(),
|
||||
seen: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// Report records a stage tick. The first tick for a given stage name
|
||||
// is treated as the stage's start timestamp.
|
||||
func (r *TimingReporter) Report(stage string, _, _ int) {
|
||||
if stage == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if idx, ok := r.seen[stage]; ok {
|
||||
r.stages[idx].ticks++
|
||||
return
|
||||
}
|
||||
r.seen[stage] = len(r.stages)
|
||||
r.stages = append(r.stages, stageEntry{name: stage, seen: time.Now(), ticks: 1})
|
||||
}
|
||||
|
||||
// WriteReport prints a two-column breakdown: per-stage duration and
|
||||
// cumulative time since the reporter was created. end defaults to
|
||||
// time.Now() when zero; pass an explicit value when the caller
|
||||
// finishes before a final "indexing complete" stage tick is emitted.
|
||||
func (r *TimingReporter) WriteReport(w io.Writer, end time.Time) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if end.IsZero() {
|
||||
end = time.Now()
|
||||
}
|
||||
if len(r.stages) == 0 {
|
||||
fmt.Fprintln(w, "no stages recorded")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%-32s %12s %12s\n", "stage", "duration", "cumulative")
|
||||
fmt.Fprintf(w, "%-32s %12s %12s\n",
|
||||
"--------------------------------", "------------", "------------")
|
||||
for i, s := range r.stages {
|
||||
var nextStart time.Time
|
||||
if i+1 < len(r.stages) {
|
||||
nextStart = r.stages[i+1].seen
|
||||
} else {
|
||||
nextStart = end
|
||||
}
|
||||
duration := nextStart.Sub(s.seen)
|
||||
cumulative := nextStart.Sub(r.start)
|
||||
fmt.Fprintf(w, "%-32s %12s %12s\n",
|
||||
s.name, formatMs(duration), formatMs(cumulative))
|
||||
}
|
||||
}
|
||||
|
||||
// formatMs formats a duration as "123ms" / "4.56s" / "1m23s" depending
|
||||
// on magnitude. Small-enough to be readable in a CLI dump.
|
||||
func formatMs(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%.2fs", d.Seconds())
|
||||
default:
|
||||
m := int(d / time.Minute)
|
||||
s := int((d % time.Minute) / time.Second)
|
||||
return fmt.Sprintf("%dm%02ds", m, s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Tracker is the CLI's live progress surface: a flicker-free animated region
|
||||
// on a capable terminal, and clean line-oriented text everywhere else (pipes,
|
||||
// CI, NO_COLOR, TERM=dumb, --no-progress, a Windows console without VT).
|
||||
//
|
||||
// The animated region is composed of an optional brand header (the gortex
|
||||
// mark lighting up with overall progress plus a title and a live status
|
||||
// line) followed by one row per step — pending, active (spinner, counters,
|
||||
// progress bar), done (check, final count, duration), failed, or skipped.
|
||||
// The whole region repaints at a fixed cadence inside terminal synchronized-
|
||||
// update brackets with every line hard-clamped to the terminal width, so the
|
||||
// frame arithmetic can never desync — the classic source of spinner ghosting.
|
||||
//
|
||||
// A Tracker is safe for concurrent use. Hot loops may call Report thousands
|
||||
// of times per second: updates only mutate state under the lock, and the
|
||||
// render goroutine samples that state at the frame rate.
|
||||
type Tracker struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
st styleSet
|
||||
|
||||
animated bool
|
||||
logo bool
|
||||
fps time.Duration
|
||||
|
||||
title string
|
||||
status string
|
||||
|
||||
steps []*Step
|
||||
hasExplicit bool
|
||||
|
||||
started bool
|
||||
finished bool
|
||||
failed bool
|
||||
finErr error
|
||||
finHead string
|
||||
finSummary string
|
||||
|
||||
startAt time.Time
|
||||
stopAt time.Time
|
||||
tick int
|
||||
|
||||
lastLines int
|
||||
flushQueue []string
|
||||
stopCh chan struct{}
|
||||
loopDone chan struct{}
|
||||
|
||||
// Test seams.
|
||||
sizeFn func() (int, int)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// stepStatus is the lifecycle of a single tracked step.
|
||||
type stepStatus int
|
||||
|
||||
const (
|
||||
stepPending stepStatus = iota
|
||||
stepActive
|
||||
stepDone
|
||||
stepFailed
|
||||
stepSkipped
|
||||
)
|
||||
|
||||
// Step is one row of the tracker: a phase of the overall run. Handles are
|
||||
// returned by AddStep / StartStep and stay valid for the tracker's lifetime.
|
||||
type Step struct {
|
||||
t *Tracker
|
||||
|
||||
label string
|
||||
doneLabel string
|
||||
status stepStatus
|
||||
cur int64
|
||||
total int64
|
||||
unit string
|
||||
note string
|
||||
err error
|
||||
skipNote string
|
||||
auto bool
|
||||
|
||||
started time.Time
|
||||
stopped time.Time
|
||||
lastBeat time.Time
|
||||
}
|
||||
|
||||
// TrackerOption customizes a Tracker at construction.
|
||||
type TrackerOption func(*Tracker)
|
||||
|
||||
// WithLogo selects the banner preset: the animated region opens with the
|
||||
// gortex mark, the title, and a live status line. Flagship multi-phase flows
|
||||
// (init, install) use it; short single-op commands keep the compact preset.
|
||||
func WithLogo() TrackerOption {
|
||||
return func(t *Tracker) { t.logo = true }
|
||||
}
|
||||
|
||||
// WithoutAnimation forces plain line output regardless of terminal
|
||||
// capabilities — the hook for the global --no-progress flag.
|
||||
func WithoutAnimation() TrackerOption {
|
||||
return func(t *Tracker) { t.animated = false }
|
||||
}
|
||||
|
||||
// NewTracker builds a tracker bound to w. Animation is auto-detected from the
|
||||
// writer and environment (see animationAllowed); pass WithoutAnimation to
|
||||
// force plain output.
|
||||
func NewTracker(w io.Writer, opts ...TrackerOption) *Tracker {
|
||||
t := &Tracker{
|
||||
w: w,
|
||||
fps: 80 * time.Millisecond,
|
||||
now: time.Now,
|
||||
}
|
||||
t.sizeFn = func() (int, int) { return termSize(w) }
|
||||
t.animated = animationAllowed(w)
|
||||
for _, o := range opts {
|
||||
o(t)
|
||||
}
|
||||
t.st = newStyleSet(w)
|
||||
return t
|
||||
}
|
||||
|
||||
// Animated reports whether the tracker renders the live animated region.
|
||||
func (t *Tracker) Animated() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.animated
|
||||
}
|
||||
|
||||
// disable forces plain mode; effective only before Start.
|
||||
func (t *Tracker) disable() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if !t.started {
|
||||
t.animated = false
|
||||
}
|
||||
}
|
||||
|
||||
// Start opens the surface with the given title and, on animated terminals,
|
||||
// begins the render loop. Idempotent.
|
||||
func (t *Tracker) Start(title string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.started {
|
||||
return
|
||||
}
|
||||
t.started = true
|
||||
t.title = title
|
||||
t.startAt = t.now()
|
||||
if !t.animated {
|
||||
if title != "" {
|
||||
fmt.Fprintf(t.w, " %s\n", title)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.stopCh = make(chan struct{})
|
||||
t.loopDone = make(chan struct{})
|
||||
_, _ = io.WriteString(t.w, ansiHideCursor)
|
||||
markCursorHidden(t.w)
|
||||
t.paintLocked(false)
|
||||
go t.loop()
|
||||
}
|
||||
|
||||
// SetTitle replaces the header title mid-run.
|
||||
func (t *Tracker) SetTitle(title string) {
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.title = title
|
||||
}
|
||||
|
||||
// SetStatus replaces the live status line (banner preset) or the inline
|
||||
// detail after the title (compact preset). Plain mode records it silently —
|
||||
// it surfaces on the finish line.
|
||||
func (t *Tracker) SetStatus(status string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.status = status
|
||||
}
|
||||
|
||||
// AddStep appends a pending step row — the design's "planned but not yet
|
||||
// running" state. Planned steps also make overall progress computable, which
|
||||
// drives the logo fill.
|
||||
func (t *Tracker) AddStep(label string) *Step {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.hasExplicit = true
|
||||
return t.newStepLocked(label, false)
|
||||
}
|
||||
|
||||
// StartStep activates the pending step with the given label, creating it
|
||||
// first when it was never planned. Returns the step handle.
|
||||
func (t *Tracker) StartStep(label string) *Step {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.hasExplicit = true
|
||||
for _, s := range t.steps {
|
||||
if s.status == stepPending && s.label == label {
|
||||
t.activateStepLocked(s)
|
||||
return s
|
||||
}
|
||||
}
|
||||
s := t.newStepLocked(label, false)
|
||||
t.activateStepLocked(s)
|
||||
return s
|
||||
}
|
||||
|
||||
func (t *Tracker) newStepLocked(label string, auto bool) *Step {
|
||||
s := &Step{t: t, label: label, auto: auto}
|
||||
t.steps = append(t.steps, s)
|
||||
return s
|
||||
}
|
||||
|
||||
func (t *Tracker) activateStepLocked(s *Step) {
|
||||
if s.status != stepPending {
|
||||
return
|
||||
}
|
||||
s.status = stepActive
|
||||
s.started = t.now()
|
||||
s.lastBeat = s.started
|
||||
if !t.animated && !t.finished {
|
||||
fmt.Fprintf(t.w, " %s ...\n", s.label)
|
||||
}
|
||||
}
|
||||
|
||||
// lastActiveLocked returns the most recently activated still-active step.
|
||||
func (t *Tracker) lastActiveLocked() *Step {
|
||||
for i := len(t.steps) - 1; i >= 0; i-- {
|
||||
if t.steps[i].status == stepActive {
|
||||
return t.steps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Progress updates the step's counters. total may be 0 when unknown.
|
||||
func (s *Step) Progress(cur, total int64) {
|
||||
t := s.t
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s.cur, s.total = cur, total
|
||||
s.plainBeatLocked()
|
||||
}
|
||||
|
||||
// SetUnit sets the dim unit suffix rendered after the counter ("files",
|
||||
// "symbols", "edges").
|
||||
func (s *Step) SetUnit(unit string) {
|
||||
s.t.mu.Lock()
|
||||
defer s.t.mu.Unlock()
|
||||
s.unit = unit
|
||||
}
|
||||
|
||||
// Note sets a dim annotation rendered after the counters — a sub-stage name,
|
||||
// the language mix, the current file.
|
||||
func (s *Step) Note(note string) {
|
||||
s.t.mu.Lock()
|
||||
defer s.t.mu.Unlock()
|
||||
s.note = note
|
||||
}
|
||||
|
||||
// Done completes the step, keeping its running label.
|
||||
func (s *Step) Done() { s.DoneAs("") }
|
||||
|
||||
// DoneAs completes the step and swaps the label for its past-tense form
|
||||
// ("parsing sources" → "symbols extracted"), matching the design language.
|
||||
func (s *Step) DoneAs(doneLabel string) {
|
||||
t := s.t
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.completeStepLocked(s, doneLabel, nil)
|
||||
}
|
||||
|
||||
// Skip marks a planned step as intentionally not run.
|
||||
func (s *Step) Skip(reason string) {
|
||||
t := s.t
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if s.status == stepDone || s.status == stepFailed || s.status == stepSkipped {
|
||||
return
|
||||
}
|
||||
s.status = stepSkipped
|
||||
s.skipNote = reason
|
||||
s.stopped = t.now()
|
||||
if !t.animated && !t.finished {
|
||||
note := ""
|
||||
if reason != "" {
|
||||
note = ": " + reason
|
||||
}
|
||||
fmt.Fprintf(t.w, " %s %s %s skipped%s\n", t.st.g.Pending, s.label, t.st.g.Dash, note)
|
||||
}
|
||||
}
|
||||
|
||||
// Fail marks the step failed with err.
|
||||
func (s *Step) Fail(err error) {
|
||||
t := s.t
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.completeStepLocked(s, "", err)
|
||||
}
|
||||
|
||||
func (t *Tracker) completeStepLocked(s *Step, doneLabel string, err error) {
|
||||
if s.status == stepDone || s.status == stepFailed || s.status == stepSkipped {
|
||||
return
|
||||
}
|
||||
if s.status == stepPending {
|
||||
s.started = t.now()
|
||||
}
|
||||
s.stopped = t.now()
|
||||
if err != nil {
|
||||
s.status = stepFailed
|
||||
s.err = err
|
||||
if !t.animated && !t.finished {
|
||||
fmt.Fprintf(t.w, " %s %s: %v\n", t.st.g.Fail, s.label, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.status = stepDone
|
||||
if doneLabel != "" {
|
||||
s.doneLabel = doneLabel
|
||||
}
|
||||
if !t.animated && !t.finished {
|
||||
fmt.Fprintf(t.w, " %s %s%s%s\n",
|
||||
t.st.g.OK, s.finalLabel(), t.plainCounts(s), t.plainDuration(s.stopped.Sub(s.started)))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Step) finalLabel() string {
|
||||
if s.doneLabel != "" {
|
||||
return s.doneLabel
|
||||
}
|
||||
return s.label
|
||||
}
|
||||
|
||||
// plainBeatLocked emits a throttled heartbeat line for a long-running step in
|
||||
// plain mode, so CI logs show liveness (and where a run hangs) without being
|
||||
// flooded by per-item ticks.
|
||||
const plainHeartbeatEvery = 10 * time.Second
|
||||
|
||||
func (s *Step) plainBeatLocked() {
|
||||
t := s.t
|
||||
if t.animated || t.finished || s.status != stepActive {
|
||||
return
|
||||
}
|
||||
now := t.now()
|
||||
if now.Sub(s.lastBeat) < plainHeartbeatEvery {
|
||||
return
|
||||
}
|
||||
s.lastBeat = now
|
||||
fmt.Fprintf(t.w, " %s%s (%s)\n", s.label, t.plainCounts(s), fmtDurationCompact(now.Sub(s.started)))
|
||||
}
|
||||
|
||||
// Report implements Reporter. Without explicit steps, every distinct stage
|
||||
// label materializes as its own step row — the previous stage completes with
|
||||
// its final counters and duration, the new one starts spinning. With explicit
|
||||
// steps (an orchestrator owns the checklist), stage ticks feed the active
|
||||
// step's note and counters instead.
|
||||
func (t *Tracker) Report(stage string, current, total int) {
|
||||
if stage == "" {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.finished {
|
||||
return
|
||||
}
|
||||
if t.hasExplicit {
|
||||
if s := t.lastActiveLocked(); s != nil {
|
||||
s.note = stage
|
||||
s.cur, s.total = int64(current), int64(total)
|
||||
s.plainBeatLocked()
|
||||
return
|
||||
}
|
||||
t.status = stage
|
||||
return
|
||||
}
|
||||
if s := t.lastActiveLocked(); s != nil && s.auto {
|
||||
if s.label == stage {
|
||||
s.cur, s.total = int64(current), int64(total)
|
||||
s.plainBeatLocked()
|
||||
return
|
||||
}
|
||||
t.completeStepLocked(s, "", nil)
|
||||
}
|
||||
s := t.newStepLocked(stage, true)
|
||||
t.activateStepLocked(s)
|
||||
s.cur, s.total = int64(current), int64(total)
|
||||
}
|
||||
|
||||
// Println writes a permanent line above the live region (or straight through
|
||||
// in plain mode). Use it for warnings and notes that must survive the
|
||||
// animation instead of writing to the tracker's writer directly.
|
||||
func (t *Tracker) Println(a ...any) {
|
||||
t.Logf("%s", strings.TrimRight(fmt.Sprintln(a...), "\n"))
|
||||
}
|
||||
|
||||
// Logf is Println with formatting.
|
||||
func (t *Tracker) Logf(format string, a ...any) {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if !t.animated || !t.started || t.finished {
|
||||
fmt.Fprintln(t.w, msg)
|
||||
return
|
||||
}
|
||||
t.flushQueue = append(t.flushQueue, strings.Split(msg, "\n")...)
|
||||
t.paintLocked(false)
|
||||
}
|
||||
|
||||
// Done finishes the run successfully. headline defaults to the title
|
||||
// (compact) or "ready" (banner); summary, when non-empty, is rendered as the
|
||||
// closing line ("indexed 2,431 files · 48,210 symbols"). Idempotent, and a
|
||||
// later Fail is ignored.
|
||||
func (t *Tracker) Done(headline, summary string) { t.finish(headline, summary, nil) }
|
||||
|
||||
// Fail finishes the run with an error: the active step (if any) is marked
|
||||
// failed and the header switches to the failure state. Idempotent.
|
||||
func (t *Tracker) Fail(err error) { t.finish("", "", err) }
|
||||
|
||||
func (t *Tracker) finish(headline, summary string, err error) {
|
||||
t.mu.Lock()
|
||||
if t.finished || !t.started {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.finished = true
|
||||
t.failed = err != nil
|
||||
t.finErr = err
|
||||
t.finHead = headline
|
||||
t.finSummary = summary
|
||||
t.stopAt = t.now()
|
||||
|
||||
// Close out the still-running step so the final frame has no orphan
|
||||
// spinner rows: success completes it, failure pins the error on it.
|
||||
if s := t.lastActiveLocked(); s != nil {
|
||||
wasAnimated := t.animated
|
||||
if err != nil {
|
||||
// Attribute the error to the step row; the header carries it too.
|
||||
s.status = stepFailed
|
||||
s.err = err
|
||||
s.stopped = t.stopAt
|
||||
} else if s.auto {
|
||||
s.status = stepDone
|
||||
s.stopped = t.stopAt
|
||||
if !wasAnimated {
|
||||
fmt.Fprintf(t.w, " %s %s%s%s\n",
|
||||
t.st.g.OK, s.finalLabel(), t.plainCounts(s), t.plainDuration(t.stopAt.Sub(s.started)))
|
||||
}
|
||||
} else {
|
||||
s.status = stepDone
|
||||
s.stopped = t.stopAt
|
||||
}
|
||||
}
|
||||
|
||||
if !t.animated {
|
||||
t.plainFinishLocked()
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
t.paintLocked(true)
|
||||
markCursorRestored(t.w)
|
||||
stop := t.stopCh
|
||||
done := t.loopDone
|
||||
t.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) plainFinishLocked() {
|
||||
elapsed := t.plainDuration(t.stopAt.Sub(t.startAt))
|
||||
if t.failed {
|
||||
if t.finErr != nil {
|
||||
fmt.Fprintf(t.w, " %s %s: %v\n", t.st.g.Fail, t.title, t.finErr)
|
||||
} else {
|
||||
fmt.Fprintf(t.w, " %s %s\n", t.st.g.Fail, t.title)
|
||||
}
|
||||
return
|
||||
}
|
||||
head := t.finHead
|
||||
if head == "" {
|
||||
head = t.title
|
||||
}
|
||||
summary := t.finSummary
|
||||
if summary == "" {
|
||||
summary = t.status
|
||||
}
|
||||
line := " " + t.st.g.OK + " " + head
|
||||
if summary != "" {
|
||||
line += " " + t.st.g.Dash + " " + summary
|
||||
}
|
||||
fmt.Fprintln(t.w, line+elapsed)
|
||||
}
|
||||
|
||||
// plainDuration renders " (dur)" for durations worth mentioning; sub-100ms
|
||||
// operations stay clean (and unit-test output deterministic).
|
||||
func (t *Tracker) plainDuration(d time.Duration) string {
|
||||
if d < 100*time.Millisecond {
|
||||
return ""
|
||||
}
|
||||
return " (" + fmtDurationCompact(d) + ")"
|
||||
}
|
||||
|
||||
func (t *Tracker) plainCounts(s *Step) string {
|
||||
if s.cur <= 0 && (s.status != stepActive || s.total <= 0) {
|
||||
return ""
|
||||
}
|
||||
out := " " + t.st.g.Sep + " " + fmtCount(s.cur)
|
||||
if s.status == stepActive && s.total > 0 {
|
||||
out += " / " + fmtCount(s.total)
|
||||
}
|
||||
if s.unit != "" {
|
||||
out += " " + s.unit
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- animated rendering ---------------------------------------------------
|
||||
|
||||
func (t *Tracker) loop() {
|
||||
defer close(t.loopDone)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
_, _ = io.WriteString(t.w, ansiShowCursor+ansiReset)
|
||||
markCursorRestored(t.w)
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
tk := time.NewTicker(t.fps)
|
||||
defer tk.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
return
|
||||
case <-tk.C:
|
||||
t.mu.Lock()
|
||||
if t.started && !t.finished {
|
||||
t.tick++
|
||||
t.paintLocked(false)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paintLocked repaints the live region. Protocol invariant: after every
|
||||
// paint the cursor parks at column 0 on the line just below the region, so
|
||||
// the next paint reaches the region top with a bare CR + cursor-up. Frames
|
||||
// are wrapped in synchronized-update brackets and every line is width-clamped
|
||||
// (a soft-wrapped line would corrupt the cursor arithmetic for all later
|
||||
// frames — the failure mode this renderer exists to eliminate).
|
||||
func (t *Tracker) paintLocked(final bool) {
|
||||
width, height := t.sizeFn()
|
||||
if width < 20 {
|
||||
width = 20
|
||||
}
|
||||
maxRegion := height - 2
|
||||
if maxRegion < 4 {
|
||||
maxRegion = 4
|
||||
}
|
||||
|
||||
lines := t.buildLinesLocked(width)
|
||||
for len(lines) > maxRegion {
|
||||
if s := t.oldestFinishedStepLocked(); s != nil {
|
||||
// Retire the oldest finished row to permanent output above the
|
||||
// region so the live region always fits the terminal.
|
||||
t.flushQueue = append(t.flushQueue, t.stepRowLocked(s, width))
|
||||
t.removeStepLocked(s)
|
||||
lines = t.buildLinesLocked(width)
|
||||
continue
|
||||
}
|
||||
lines = lines[len(lines)-maxRegion:]
|
||||
break
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(ansiSyncStart)
|
||||
b.WriteString("\r")
|
||||
b.WriteString(ansiUp(t.lastLines))
|
||||
if len(t.flushQueue) > 0 {
|
||||
b.WriteString(ansiClearBelow)
|
||||
for _, fl := range t.flushQueue {
|
||||
b.WriteString(clampLine(fl, width-1, t.st.g.Ellipsis))
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
t.flushQueue = nil
|
||||
t.lastLines = 0
|
||||
}
|
||||
for _, ln := range lines {
|
||||
b.WriteString(clampLine(ln, width-1, t.st.g.Ellipsis))
|
||||
b.WriteString(ansiClearEOL)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
if len(lines) < t.lastLines {
|
||||
b.WriteString(ansiClearBelow)
|
||||
}
|
||||
t.lastLines = len(lines)
|
||||
if final {
|
||||
b.WriteString(ansiShowCursor)
|
||||
b.WriteString(ansiReset)
|
||||
}
|
||||
b.WriteString(ansiSyncEnd)
|
||||
_, _ = io.WriteString(t.w, b.String())
|
||||
}
|
||||
|
||||
func (t *Tracker) oldestFinishedStepLocked() *Step {
|
||||
for _, s := range t.steps {
|
||||
if s.status == stepDone || s.status == stepSkipped || s.status == stepFailed {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tracker) removeStepLocked(target *Step) {
|
||||
for i, s := range t.steps {
|
||||
if s == target {
|
||||
t.steps = append(t.steps[:i], t.steps[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) buildLinesLocked(width int) []string {
|
||||
var lines []string
|
||||
if t.logo {
|
||||
frac := t.logoFracLocked()
|
||||
logo := logoLines(t.st.logoLit, t.st.logoDim, t.st.accent, t.st.g, frac, t.tick, -1)
|
||||
title := ""
|
||||
if t.title != "" {
|
||||
title = " " + t.st.title.Render(t.title)
|
||||
}
|
||||
lines = append(lines,
|
||||
" "+logo[0],
|
||||
" "+logo[1]+title,
|
||||
" "+logo[2]+" "+t.statusRowLocked(),
|
||||
" "+logo[3],
|
||||
" "+logo[4],
|
||||
)
|
||||
if len(t.steps) > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, t.compactHeaderLocked())
|
||||
}
|
||||
for _, s := range t.steps {
|
||||
lines = append(lines, t.stepRowLocked(s, width))
|
||||
}
|
||||
if t.logo && t.finished && !t.failed && t.finSummary != "" {
|
||||
lines = append(lines, "", " "+t.st.accent.Render(t.st.g.OK)+" "+t.st.dim.Render(t.finSummary))
|
||||
}
|
||||
if t.logo && t.finished && t.failed && t.finErr != nil && t.lastFailedStepLocked() == nil {
|
||||
lines = append(lines, "", " "+t.st.err.Render(t.st.g.Fail+" "+t.finErr.Error()))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (t *Tracker) lastFailedStepLocked() *Step {
|
||||
for i := len(t.steps) - 1; i >= 0; i-- {
|
||||
if t.steps[i].status == stepFailed {
|
||||
return t.steps[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logoFracLocked derives the logo fill fraction. Planned checklists light the
|
||||
// mark progressively (done steps plus the active step's own fraction);
|
||||
// unplanned auto-step runs use the indeterminate marquee until they finish.
|
||||
func (t *Tracker) logoFracLocked() float64 {
|
||||
if t.finished && !t.failed {
|
||||
return 1
|
||||
}
|
||||
if !t.hasExplicit || len(t.steps) == 0 {
|
||||
if t.finished {
|
||||
return 0 // failed with no checklist: dim mark, the ✗ carries the news
|
||||
}
|
||||
return -1
|
||||
}
|
||||
var done, active float64
|
||||
for _, s := range t.steps {
|
||||
switch s.status {
|
||||
case stepDone, stepSkipped, stepFailed:
|
||||
done++
|
||||
case stepActive:
|
||||
if s.total > 0 {
|
||||
f := float64(s.cur) / float64(s.total)
|
||||
if f > 1 {
|
||||
f = 1
|
||||
}
|
||||
if f > active {
|
||||
active = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (done + active) / float64(len(t.steps))
|
||||
}
|
||||
|
||||
func (t *Tracker) elapsedLocked() string {
|
||||
end := t.now()
|
||||
if t.finished {
|
||||
end = t.stopAt
|
||||
}
|
||||
return fmtDurationCompact(end.Sub(t.startAt))
|
||||
}
|
||||
|
||||
func (t *Tracker) statusRowLocked() string {
|
||||
elapsed := t.st.dimmer.Render(" " + t.st.g.Sep + " " + t.elapsedLocked())
|
||||
switch {
|
||||
case t.finished && t.failed:
|
||||
return t.st.err.Render(t.st.g.Fail+" failed") + elapsed
|
||||
case t.finished:
|
||||
head := t.finHead
|
||||
if head == "" || head == t.title {
|
||||
head = "ready"
|
||||
}
|
||||
return t.st.accent.Render(t.st.g.StatusReady) + " " + t.st.accentBold.Render(head) + elapsed
|
||||
default:
|
||||
text := t.status
|
||||
if text == "" {
|
||||
text = t.defaultStatusLocked()
|
||||
}
|
||||
spin := t.st.spin[t.tick%len(t.st.spin)]
|
||||
return t.st.accentDim.Render(spin) + " " + t.st.dim.Render(text) + elapsed
|
||||
}
|
||||
}
|
||||
|
||||
// defaultStatusLocked derives a status when the caller set none: the phase
|
||||
// position for a planned checklist ("phase 3 / 5"), the active stage label
|
||||
// for reporter-driven runs, "working" as the last resort.
|
||||
func (t *Tracker) defaultStatusLocked() string {
|
||||
if t.hasExplicit && len(t.steps) > 0 {
|
||||
done := 0
|
||||
anyActive := false
|
||||
for _, s := range t.steps {
|
||||
switch s.status {
|
||||
case stepDone, stepSkipped, stepFailed:
|
||||
done++
|
||||
case stepActive:
|
||||
anyActive = true
|
||||
}
|
||||
}
|
||||
cur := done
|
||||
if anyActive && cur < len(t.steps) {
|
||||
cur++
|
||||
}
|
||||
if cur == 0 {
|
||||
cur = 1
|
||||
}
|
||||
return fmt.Sprintf("phase %d / %d", cur, len(t.steps))
|
||||
}
|
||||
if s := t.lastActiveLocked(); s != nil {
|
||||
return s.label
|
||||
}
|
||||
return "working"
|
||||
}
|
||||
|
||||
func (t *Tracker) compactHeaderLocked() string {
|
||||
elapsed := t.st.dimmer.Render(" " + t.st.g.Sep + " " + t.elapsedLocked())
|
||||
switch {
|
||||
case t.finished && t.failed:
|
||||
line := " " + t.st.err.Render(t.st.g.Fail) + " " + t.st.title.Render(t.title)
|
||||
if t.finErr != nil {
|
||||
line += t.st.err.Render(": " + t.finErr.Error())
|
||||
}
|
||||
return line + elapsed
|
||||
case t.finished:
|
||||
head := t.finHead
|
||||
if head == "" {
|
||||
head = t.title
|
||||
}
|
||||
line := " " + t.st.accent.Render(t.st.g.OK) + " " + t.st.title.Render(head)
|
||||
summary := t.finSummary
|
||||
if summary == "" {
|
||||
summary = t.status
|
||||
}
|
||||
if summary != "" {
|
||||
line += t.st.dim.Render(" " + t.st.g.Dash + " " + summary)
|
||||
}
|
||||
return line + elapsed
|
||||
default:
|
||||
mark := t.st.accentDim.Render(t.st.spin[t.tick%len(t.st.spin)])
|
||||
if len(t.steps) > 0 {
|
||||
mark = t.st.dimmer.Render(t.st.g.StatusBusy)
|
||||
}
|
||||
line := " " + mark + " " + t.st.title.Render(t.title)
|
||||
if t.status != "" {
|
||||
line += t.st.dim.Render(" " + t.st.g.Dash + " " + t.status)
|
||||
}
|
||||
return line + elapsed
|
||||
}
|
||||
}
|
||||
|
||||
// barCells is the progress bar width, matching the design's 22-cell bar.
|
||||
const barCells = 22
|
||||
|
||||
func (t *Tracker) stepRowLocked(s *Step, width int) string {
|
||||
switch s.status {
|
||||
case stepPending:
|
||||
return " " + t.st.dimmer.Render(t.st.g.Pending) + " " + t.st.dimmer.Render(s.label)
|
||||
case stepSkipped:
|
||||
note := ""
|
||||
if s.skipNote != "" {
|
||||
note = ": " + s.skipNote
|
||||
}
|
||||
return " " + t.st.dimmer.Render(t.st.g.Pending) + " " +
|
||||
t.st.dimmer.Render(s.label+" "+t.st.g.Dash+" skipped"+note)
|
||||
case stepFailed:
|
||||
msg := ""
|
||||
if s.err != nil {
|
||||
msg = t.st.err.Render(" " + t.st.g.Dash + " " + s.err.Error())
|
||||
}
|
||||
return " " + t.st.err.Render(t.st.g.Fail) + " " + t.st.fg.Render(s.label) + msg
|
||||
case stepDone:
|
||||
row := " " + t.st.accent.Render(t.st.g.OK) + " " + t.st.fg.Render(s.finalLabel()) + t.countsLocked(s, true)
|
||||
if d := s.stopped.Sub(s.started); d >= 100*time.Millisecond {
|
||||
row += t.st.dimmer.Render(" (" + fmtDurationCompact(d) + ")")
|
||||
}
|
||||
return row
|
||||
default: // active
|
||||
spin := t.st.spin[t.tick%len(t.st.spin)]
|
||||
base := " " + t.st.accentDim.Render(spin) + " " + t.st.fg.Render(s.label) + t.countsLocked(s, false)
|
||||
note := ""
|
||||
if s.note != "" {
|
||||
note = t.st.dimmer.Render(" " + s.note)
|
||||
}
|
||||
bar := ""
|
||||
if s.total > 0 {
|
||||
bar = t.barLocked(s)
|
||||
}
|
||||
// Fit by priority instead of mid-bar truncation: full row, then drop
|
||||
// the note, then drop the bar. The label and counters always stay.
|
||||
for _, row := range []string{base + note + bar, base + bar, base + note} {
|
||||
if visibleWidth(row) <= width-1 {
|
||||
return row
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) countsLocked(s *Step, done bool) string {
|
||||
if s.cur <= 0 && (done || s.total <= 0) {
|
||||
return ""
|
||||
}
|
||||
out := t.st.dim.Render(" "+t.st.g.Sep+" ") + t.st.fg.Render(fmtCount(s.cur))
|
||||
if !done && s.total > 0 {
|
||||
out += t.st.dim.Render(" / " + fmtCount(s.total))
|
||||
}
|
||||
if s.unit != "" {
|
||||
out += t.st.dim.Render(" " + s.unit)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *Tracker) barLocked(s *Step) string {
|
||||
frac := float64(s.cur) / float64(s.total)
|
||||
if frac < 0 {
|
||||
frac = 0
|
||||
}
|
||||
if frac > 1 {
|
||||
frac = 1
|
||||
}
|
||||
fill := int(frac*barCells + 0.5)
|
||||
pct := int(frac*100 + 0.5)
|
||||
return " " + t.st.accent.Render(strings.Repeat(t.st.g.BarFull, fill)) +
|
||||
t.st.dimmer.Render(strings.Repeat(t.st.g.BarEmpty, barCells-fill)) +
|
||||
t.st.dim.Render(fmt.Sprintf(" %3d%%", pct))
|
||||
}
|
||||
|
||||
// ---- styles ---------------------------------------------------------------
|
||||
|
||||
// styleSet bundles the glyphs, spinner frames, and writer-bound styles one
|
||||
// tracker instance renders with. Styles are bound to the tracker's own writer
|
||||
// (not the process stdout) so `gortex … | tee log` keeps colors on the
|
||||
// animated stderr region.
|
||||
type styleSet struct {
|
||||
g glyphSet
|
||||
spin []string
|
||||
|
||||
fg lipgloss.Style
|
||||
dim lipgloss.Style
|
||||
dimmer lipgloss.Style
|
||||
accent lipgloss.Style
|
||||
accentBold lipgloss.Style
|
||||
accentDim lipgloss.Style
|
||||
err lipgloss.Style
|
||||
title lipgloss.Style
|
||||
logoLit lipgloss.Style
|
||||
logoDim lipgloss.Style
|
||||
}
|
||||
|
||||
func newStyleSet(w io.Writer) styleSet {
|
||||
r := lipgloss.NewRenderer(w)
|
||||
r.SetColorProfile(colorProfileFor(w))
|
||||
mk := func(c lipgloss.Color) lipgloss.Style { return r.NewStyle().Foreground(c) }
|
||||
return styleSet{
|
||||
g: activeGlyphs(),
|
||||
spin: spinFrames(),
|
||||
fg: mk(colFg),
|
||||
dim: mk(colFgDim),
|
||||
dimmer: mk(colMuted),
|
||||
accent: mk(colAccent),
|
||||
accentBold: r.NewStyle().Foreground(colAccent).Bold(true),
|
||||
accentDim: mk(colAccentDim),
|
||||
err: mk(colErr),
|
||||
title: r.NewStyle().Foreground(colFg).Bold(true),
|
||||
logoLit: mk(colPerim),
|
||||
logoDim: mk(colInner),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// forceUnicode pins the glyph set so assertions are stable regardless of the
|
||||
// host environment.
|
||||
func forceUnicode(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("GORTEX_ASCII", "")
|
||||
t.Setenv("GORTEX_UNICODE", "1")
|
||||
}
|
||||
|
||||
// newAnimatedTestTracker returns a tracker that renders the animated protocol
|
||||
// into buf: animation forced (no TTY in tests), the frame ticker frozen so
|
||||
// paints happen only at Start / Logf / finish, and a fixed terminal size.
|
||||
func newAnimatedTestTracker(t *testing.T, buf *bytes.Buffer, w, h int, opts ...TrackerOption) *Tracker {
|
||||
t.Helper()
|
||||
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
|
||||
tr := NewTracker(buf, opts...)
|
||||
tr.fps = time.Hour
|
||||
tr.sizeFn = func() (int, int) { return w, h }
|
||||
return tr
|
||||
}
|
||||
|
||||
func TestTrackerPlainExplicitStepsLifecycle(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
tr := NewTracker(&buf, WithoutAnimation())
|
||||
tr.Start("gortex init")
|
||||
|
||||
scan := tr.AddStep("scan repository")
|
||||
parse := tr.AddStep("parse sources")
|
||||
analyze := tr.AddStep("analyze codebase")
|
||||
broken := tr.AddStep("configure adapters")
|
||||
if strings.Contains(buf.String(), "scan repository") {
|
||||
t.Fatalf("pending steps must stay silent in plain mode, got:\n%s", buf.String())
|
||||
}
|
||||
|
||||
tr.StartStep("scan repository")
|
||||
scan.Progress(1338, 0)
|
||||
scan.SetUnit("files")
|
||||
scan.DoneAs("files discovered")
|
||||
|
||||
tr.StartStep("parse sources")
|
||||
parse.Progress(48210, 0)
|
||||
parse.SetUnit("symbols")
|
||||
parse.Done()
|
||||
|
||||
analyze.Skip("disabled by flag")
|
||||
|
||||
tr.StartStep("configure adapters")
|
||||
broken.Fail(errors.New("no adapters selected"))
|
||||
|
||||
tr.Done("ready", "indexed 1,338 files · 48,210 symbols")
|
||||
|
||||
out := buf.String()
|
||||
wants := []string{
|
||||
" gortex init",
|
||||
" scan repository ...",
|
||||
" ✓ files discovered · 1,338 files",
|
||||
" parse sources ...",
|
||||
" ✓ parse sources · 48,210 symbols",
|
||||
" · analyze codebase — skipped: disabled by flag",
|
||||
" configure adapters ...",
|
||||
" ✗ configure adapters: no adapters selected",
|
||||
" ✓ ready — indexed 1,338 files · 48,210 symbols",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("plain output missing %q\n--- got ---\n%s", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerPlainHeartbeatThrottled(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
tr := NewTracker(&buf, WithoutAnimation())
|
||||
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
tr.now = func() time.Time { return clock }
|
||||
tr.Start("indexing")
|
||||
|
||||
s := tr.StartStep("resolving references")
|
||||
s.Progress(1, 10)
|
||||
s.Progress(2, 10) // within the throttle window — silent
|
||||
if got := strings.Count(buf.String(), "resolving references"); got != 1 {
|
||||
t.Fatalf("ticks inside the throttle window must not print, got %d occurrences:\n%s", got, buf.String())
|
||||
}
|
||||
|
||||
clock = clock.Add(11 * time.Second)
|
||||
s.Progress(5, 10)
|
||||
if !strings.Contains(buf.String(), " resolving references · 5 / 10 (11s)") {
|
||||
t.Errorf("expected a heartbeat line after the throttle window, got:\n%s", buf.String())
|
||||
}
|
||||
|
||||
s.Progress(6, 10) // window reset — silent again
|
||||
if got := strings.Count(buf.String(), "resolving references"); got != 2 {
|
||||
t.Errorf("heartbeat must rearm the throttle, got %d occurrences:\n%s", got, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerAnimatedFrameProtocol(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
tr := newAnimatedTestTracker(t, &buf, 80, 24)
|
||||
tr.Start("gortex enrich")
|
||||
|
||||
s := tr.StartStep("stamping blame")
|
||||
s.Progress(120, 400)
|
||||
s.SetUnit("nodes")
|
||||
s.Done()
|
||||
tr.Done("", "400 nodes stamped")
|
||||
|
||||
out := buf.String()
|
||||
if !strings.HasPrefix(out, ansiHideCursor) {
|
||||
t.Errorf("animated stream must open by hiding the cursor, got prefix %q", out[:min(20, len(out))])
|
||||
}
|
||||
if !strings.Contains(out, ansiSyncStart) || !strings.Contains(out, ansiSyncEnd) {
|
||||
t.Error("frames must be wrapped in synchronized-update brackets")
|
||||
}
|
||||
if strings.Count(out, ansiSyncStart) != strings.Count(out, ansiSyncEnd) {
|
||||
t.Error("unbalanced synchronized-update brackets")
|
||||
}
|
||||
if !strings.Contains(out, ansiShowCursor) {
|
||||
t.Error("final frame must re-show the cursor")
|
||||
}
|
||||
if !strings.Contains(out, ansiReset) {
|
||||
t.Error("final frame must reset SGR state")
|
||||
}
|
||||
// The final frame carries the completed checklist and the summary.
|
||||
for _, w := range []string{"✓ stamping blame", "400 nodes stamped", "gortex enrich"} {
|
||||
if !strings.Contains(ansi.Strip(out), w) {
|
||||
t.Errorf("final frame missing %q\n--- stripped ---\n%s", w, ansi.Strip(out))
|
||||
}
|
||||
}
|
||||
// Every frame line terminates with clear-to-EOL + CRLF so repaints can
|
||||
// never leave residue and the cursor arithmetic stays exact.
|
||||
frames := strings.Split(out, ansiSyncStart)
|
||||
for _, fr := range frames[1:] {
|
||||
body := strings.TrimSuffix(fr, ansiSyncEnd)
|
||||
for _, ln := range strings.Split(body, "\r\n") {
|
||||
if strings.Contains(ln, "\n") {
|
||||
t.Errorf("bare LF inside a frame line: %q", ln)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerAnimatedLinesNeverExceedWidth(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
const width = 34
|
||||
var buf bytes.Buffer
|
||||
tr := newAnimatedTestTracker(t, &buf, width, 24)
|
||||
tr.Start("gortex enrich with an extremely long command title that cannot fit")
|
||||
|
||||
s := tr.StartStep("a step label that is much longer than the terminal width for sure")
|
||||
s.Progress(123456, 654321)
|
||||
s.SetUnit("symbols")
|
||||
s.Note("annotation that also overflows the width")
|
||||
tr.Done("", "")
|
||||
|
||||
for _, raw := range strings.Split(buf.String(), "\r\n") {
|
||||
if w := ansi.StringWidth(raw); w > width-1 {
|
||||
t.Errorf("frame line exceeds clamp width %d (got %d): %q", width-1, w, ansi.Strip(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerOverflowRetiresFinishedRows(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
tr := newAnimatedTestTracker(t, &buf, 80, 8) // maxRegion = 6
|
||||
tr.Start("bulk run")
|
||||
for i := 0; i < 10; i++ {
|
||||
s := tr.StartStep(fmt.Sprintf("phase-%02d", i))
|
||||
s.Done()
|
||||
}
|
||||
tr.Done("", "")
|
||||
|
||||
stripped := ansi.Strip(buf.String())
|
||||
for i := 0; i < 10; i++ {
|
||||
label := fmt.Sprintf("phase-%02d", i)
|
||||
if !strings.Contains(stripped, label) {
|
||||
t.Errorf("finished step %s lost during overflow handling\n--- got ---\n%s", label, stripped)
|
||||
}
|
||||
}
|
||||
// The final live region must fit the clamped height. Region lines are the
|
||||
// ones terminated by clear-to-EOL; retired rows above the region are
|
||||
// written without it.
|
||||
frames := strings.Split(buf.String(), ansiSyncStart)
|
||||
last := frames[len(frames)-1]
|
||||
if lines := strings.Count(last, ansiClearEOL); lines > 6 {
|
||||
t.Errorf("final live region paints %d lines, exceeding the clamp of 6", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerLogfWritesAboveRegion(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
tr := newAnimatedTestTracker(t, &buf, 80, 24)
|
||||
tr.Start("watching")
|
||||
tr.Logf("warning: adapter %s misbehaved", "codex")
|
||||
tr.Done("", "")
|
||||
|
||||
out := ansi.Strip(buf.String())
|
||||
warnIdx := strings.Index(out, "warning: adapter codex misbehaved")
|
||||
if warnIdx < 0 {
|
||||
t.Fatalf("log line missing from animated stream:\n%s", out)
|
||||
}
|
||||
// The region is repainted after the log line, so the title must appear
|
||||
// again later in the stream.
|
||||
if !strings.Contains(out[warnIdx:], "watching") {
|
||||
t.Errorf("live region not repainted after log line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerReporterBuildsChecklistAnimated(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
tr := newAnimatedTestTracker(t, &buf, 100, 24)
|
||||
tr.Start("indexing repository")
|
||||
tr.Report("discovering files", 0, 0)
|
||||
tr.Report("discovering files", 900, 0)
|
||||
tr.Report("parsing", 10, 900)
|
||||
tr.Report("parsing", 900, 900)
|
||||
tr.Done("", "")
|
||||
|
||||
out := ansi.Strip(buf.String())
|
||||
if !strings.Contains(out, "✓ discovering files · 900") {
|
||||
t.Errorf("previous stage must complete with its final counter:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "✓ parsing · 900") {
|
||||
t.Errorf("last stage must be completed by Done:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerBannerPresetRendersLogoAndStatus(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
tr := newAnimatedTestTracker(t, &buf, 100, 30, WithLogo())
|
||||
tr.Start("gortex init")
|
||||
tr.AddStep("index repository")
|
||||
tr.AddStep("configure adapters")
|
||||
tr.StartStep("index repository").Done()
|
||||
tr.SetStatus("configuring")
|
||||
tr.StartStep("configure adapters").Done()
|
||||
tr.Done("ready", "indexed everything")
|
||||
|
||||
out := ansi.Strip(buf.String())
|
||||
if got := strings.Count(out, "●"); got < 10 {
|
||||
t.Errorf("banner preset must render the dot-matrix mark, found %d dots:\n%s", got, out)
|
||||
}
|
||||
for _, w := range []string{"gortex init", "ready", "indexed everything", "✓ index repository", "✓ configure adapters"} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("banner output missing %q:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerConcurrentReportsAreSafe(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
|
||||
var buf bytes.Buffer
|
||||
tr := NewTracker(&buf)
|
||||
tr.fps = 2 * time.Millisecond
|
||||
tr.sizeFn = func() (int, int) { return 80, 24 }
|
||||
tr.Start("stress")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
tr.Report(fmt.Sprintf("stage-%d", g%3), i, 200)
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
time.Sleep(10 * time.Millisecond) // let the ticker paint at least once
|
||||
tr.Done("", "")
|
||||
if !strings.Contains(ansi.Strip(buf.String()), "stress") {
|
||||
t.Error("expected output from the concurrent run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerFinishIdempotent(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
tr := NewTracker(&buf, WithoutAnimation())
|
||||
tr.Start("run")
|
||||
tr.Done("", "")
|
||||
tr.Done("", "")
|
||||
tr.Fail(errors.New("late"))
|
||||
|
||||
out := buf.String()
|
||||
if got := strings.Count(out, "✓"); got != 1 {
|
||||
t.Errorf("expected exactly one ✓, got %d:\n%s", got, out)
|
||||
}
|
||||
if strings.Contains(out, "✗") {
|
||||
t.Errorf("Fail after Done must be ignored:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpinnerCompatStatusRidesFinishLine(t *testing.T) {
|
||||
forceUnicode(t)
|
||||
var buf bytes.Buffer
|
||||
sp := NewSpinner(&buf)
|
||||
sp.Disable()
|
||||
sp.Start("Enriched via daemon")
|
||||
sp.Set("", "42 nodes stamped")
|
||||
sp.Done()
|
||||
|
||||
if !strings.Contains(buf.String(), "✓ Enriched via daemon — 42 nodes stamped") {
|
||||
t.Errorf("finish line must carry the last status, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Extended palette — used by the post-summary helpers.
|
||||
var (
|
||||
colWarn = lipgloss.Color("#E8C66B")
|
||||
colInfoSoft = lipgloss.Color("#6EAAD4")
|
||||
colMuted = lipgloss.Color("#5A5A60")
|
||||
colBorder = lipgloss.Color("#3A3A40")
|
||||
)
|
||||
|
||||
// Composed styles.
|
||||
var (
|
||||
styleHeading = lipgloss.NewStyle().Foreground(colFgDim).Bold(true)
|
||||
styleCount = lipgloss.NewStyle().Foreground(colMuted)
|
||||
styleKey = lipgloss.NewStyle().Foreground(colFg).Bold(true)
|
||||
styleVal = lipgloss.NewStyle().Foreground(colFgDim)
|
||||
styleHint = lipgloss.NewStyle().Foreground(colMuted).Italic(true)
|
||||
styleStep = lipgloss.NewStyle().Foreground(colInfoSoft)
|
||||
styleStrong = lipgloss.NewStyle().Foreground(colFg).Bold(true)
|
||||
// styleBox carries everything but the border charset; Card applies the
|
||||
// active (rounded or ASCII) border at render time so an OEM-codepage
|
||||
// terminal gets a box it can actually draw.
|
||||
styleBox = lipgloss.NewStyle().
|
||||
BorderForeground(colBorder).
|
||||
Padding(0, 1)
|
||||
)
|
||||
|
||||
// Heading returns a section header on its own line: lowercase title,
|
||||
// optional dim count chip. No leading indent — caller controls layout.
|
||||
func Heading(title string, count ...string) string {
|
||||
t := styleHeading.Render(strings.ToLower(title))
|
||||
if len(count) > 0 && count[0] != "" {
|
||||
t += " " + styleCount.Render(activeGlyphs().Sep+" "+count[0])
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Caption renders a one-line dim hint.
|
||||
func Caption(text string) string {
|
||||
return styleHint.Render(text)
|
||||
}
|
||||
|
||||
// Row renders one row of a 2-column listing: bold key (padded to keyWidth)
|
||||
// followed by dim details. No leading indent.
|
||||
func Row(key, details string, keyWidth int) string {
|
||||
return styleKey.Render(padRight(key, keyWidth)) + " " + styleVal.Render(details)
|
||||
}
|
||||
|
||||
// NumberedStep renders a numbered step (" 1. text") with the index in soft
|
||||
// blue. Used by post-run summary cards for "next steps" hints.
|
||||
func NumberedStep(n int, text string) string {
|
||||
return styleStep.Render(fmt.Sprintf("%d.", n)) + " " + styleVal.Render(text)
|
||||
}
|
||||
|
||||
// Chips renders items as " · " separated dim text, fitted to width if width
|
||||
// is positive (otherwise a single-line list).
|
||||
func Chips(items []string, width int) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
sep := " " + activeGlyphs().Sep + " "
|
||||
if width <= 0 {
|
||||
return styleVal.Render(strings.Join(items, sep))
|
||||
}
|
||||
// Width-aware multi-line wrap.
|
||||
var lines []string
|
||||
var cur strings.Builder
|
||||
for i, it := range items {
|
||||
piece := it
|
||||
if i > 0 {
|
||||
piece = sep + piece
|
||||
}
|
||||
if cur.Len()+lipgloss.Width(piece) > width && cur.Len() > 0 {
|
||||
lines = append(lines, cur.String())
|
||||
cur.Reset()
|
||||
cur.WriteString(it)
|
||||
} else {
|
||||
cur.WriteString(piece)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
lines = append(lines, cur.String())
|
||||
}
|
||||
for i := range lines {
|
||||
lines[i] = styleVal.Render(lines[i])
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Stat renders "value unit" with an emphasised value (bold, given color)
|
||||
// and dim unit. Pass an empty unit to skip the suffix.
|
||||
type StatSeverity int
|
||||
|
||||
const (
|
||||
StatNeutral StatSeverity = iota
|
||||
StatGood
|
||||
StatWarn
|
||||
StatBad
|
||||
)
|
||||
|
||||
func Stat(value, unit string, sev StatSeverity) string {
|
||||
color := colFg
|
||||
switch sev {
|
||||
case StatGood:
|
||||
color = colAccent
|
||||
case StatWarn:
|
||||
color = colWarn
|
||||
case StatBad:
|
||||
color = colErr
|
||||
}
|
||||
v := lipgloss.NewStyle().Foreground(color).Bold(true).Render(value)
|
||||
if unit == "" {
|
||||
return v
|
||||
}
|
||||
return v + " " + styleVal.Render(unit)
|
||||
}
|
||||
|
||||
// StatStrip renders multiple Stats joined with a mid-dot separator.
|
||||
func StatStrip(stats ...string) string {
|
||||
return strings.Join(stats, styleVal.Render(" "+activeGlyphs().Sep+" "))
|
||||
}
|
||||
|
||||
// Card wraps body in a dim rounded box with an optional title row above. Pad
|
||||
// is the inner padding rows. Returns the box plus a trailing newline.
|
||||
func Card(title, body string) string {
|
||||
if title != "" {
|
||||
body = styleStrong.Render(title) + "\n" + body
|
||||
}
|
||||
return styleBox.Border(activeGlyphs().Border).Render(body) + "\n"
|
||||
}
|
||||
|
||||
// Indent prefixes every line of s with the given number of spaces.
|
||||
func Indent(s string, n int) string {
|
||||
pad := strings.Repeat(" ", n)
|
||||
return pad + strings.ReplaceAll(s, "\n", "\n"+pad)
|
||||
}
|
||||
|
||||
// SortStrings is a small convenience used by callers preparing chip lists.
|
||||
func SortStrings(s []string) []string {
|
||||
out := append([]string(nil), s...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func padRight(s string, n int) string {
|
||||
w := lipgloss.Width(s)
|
||||
if w >= n {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", n-w)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package progress
|
||||
|
||||
import "io"
|
||||
|
||||
// enableVT is a no-op outside Windows: every unix terminal that passed the
|
||||
// TTY gate interprets VT sequences natively.
|
||||
func enableVT(io.Writer) bool { return true }
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package progress
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// enableVT switches the console behind w into VT-processing mode so the
|
||||
// animated renderer's escape sequences are interpreted instead of echoed.
|
||||
// Windows Terminal and recent conhost builds accept it; a console that
|
||||
// refuses (pre-1511 conhost, exotic redirectors) returns false and the
|
||||
// caller stays on plain output.
|
||||
func enableVT(w io.Writer) bool {
|
||||
f, ok := w.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
h := windows.Handle(f.Fd())
|
||||
var mode uint32
|
||||
if windows.GetConsoleMode(h, &mode) != nil {
|
||||
return false
|
||||
}
|
||||
if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 {
|
||||
return true
|
||||
}
|
||||
return windows.SetConsoleMode(h, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ZapReporter logs every Report call as a zap INFO line. Used in
|
||||
// non-TTY environments (the daemon, CI) where the Spinner is
|
||||
// silent so progress is invisible. Stage transitions get logged
|
||||
// immediately; intra-stage progress (current/total) gets logged on
|
||||
// transition AND every progressInterval seconds so a slow stage
|
||||
// emits a heartbeat instead of going quiet.
|
||||
type ZapReporter struct {
|
||||
logger *zap.Logger
|
||||
prefix string
|
||||
interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
lastStage string
|
||||
stageStart time.Time
|
||||
lastEmitted time.Time
|
||||
lastCur int
|
||||
lastTotal int
|
||||
}
|
||||
|
||||
// NewZapReporter creates a reporter that logs to the given logger.
|
||||
// prefix is added to every log line ("indexer", "multi-repo", …).
|
||||
// interval is the heartbeat cadence for intra-stage progress
|
||||
// (0 disables heartbeats — only stage transitions log).
|
||||
func NewZapReporter(logger *zap.Logger, prefix string, interval time.Duration) *ZapReporter {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &ZapReporter{
|
||||
logger: logger,
|
||||
prefix: prefix,
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// Report records a stage advancement. Always logs on a stage
|
||||
// transition; logs intra-stage updates at most once per interval.
|
||||
func (r *ZapReporter) Report(stage string, cur, total int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
now := time.Now()
|
||||
if stage != r.lastStage {
|
||||
if r.lastStage != "" {
|
||||
r.logger.Info(r.prefix+": stage end",
|
||||
zap.String("stage", r.lastStage),
|
||||
zap.Duration("elapsed", now.Sub(r.stageStart)),
|
||||
)
|
||||
}
|
||||
r.lastStage = stage
|
||||
r.stageStart = now
|
||||
r.lastEmitted = now
|
||||
r.lastCur = cur
|
||||
r.lastTotal = total
|
||||
r.logger.Info(r.prefix+": stage start",
|
||||
zap.String("stage", stage),
|
||||
zap.Int("current", cur),
|
||||
zap.Int("total", total),
|
||||
)
|
||||
return
|
||||
}
|
||||
// Same stage — heartbeat at most once per interval.
|
||||
if r.interval > 0 && now.Sub(r.lastEmitted) < r.interval {
|
||||
return
|
||||
}
|
||||
r.lastEmitted = now
|
||||
r.lastCur = cur
|
||||
r.lastTotal = total
|
||||
r.logger.Info(r.prefix+": stage progress",
|
||||
zap.String("stage", stage),
|
||||
zap.Int("current", cur),
|
||||
zap.Int("total", total),
|
||||
zap.Duration("elapsed", now.Sub(r.stageStart)),
|
||||
)
|
||||
}
|
||||
|
||||
// StartHeartbeat runs a goroutine that logs an "alive" line every
|
||||
// interval until the context is done. Useful when the indexer is
|
||||
// inside a long-running phase that doesn't call Report itself
|
||||
// (e.g. the disk backend's bulk writes during a slow drain).
|
||||
func StartHeartbeat(ctx context.Context, logger *zap.Logger, prefix string, interval time.Duration, snapshot func() map[string]any) {
|
||||
if logger == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
start := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
fields := []zap.Field{
|
||||
zap.Duration("elapsed", time.Since(start)),
|
||||
}
|
||||
if snapshot != nil {
|
||||
for k, v := range snapshot() {
|
||||
fields = append(fields, zap.Any(k, v))
|
||||
}
|
||||
}
|
||||
logger.Info(prefix+": heartbeat", fields...)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user