Files
stack-sync/tui.go
T
LyAhn dfc6218e45 feat(tui): surface live sync progress and failures
Stream repository start and completion events into the Bubble Tea update loop
so long runs show elapsed time, active repositories, a progress bar, live
outcome counts, and the latest completed result.

Keep an actionable summary after completion, list failures before warnings,
focus the first failed repository automatically, and add the f shortcut for
cycling through failed, skipped, protected, or divergent repositories.
2026-09-06 20:19:05 +01:00

639 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"io"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
amber = lipgloss.Color("214")
green = lipgloss.Color("42")
red = lipgloss.Color("196")
muted = lipgloss.Color("241")
panel = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("238")).Padding(0, 1)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(amber)
readyStyle = lipgloss.NewStyle().Foreground(green)
blockStyle = lipgloss.NewStyle().Foreground(red)
mutedStyle = lipgloss.NewStyle().Foreground(muted)
)
type tuiMode int
const (
modeReady tuiMode = iota
modeConfirm
modeSyncing
)
type tickMsg time.Time
type scanDoneMsg struct {
repos []repo
err error
}
type syncDoneMsg struct {
results []syncResult
repos []repo
err error
}
type syncStartedMsg struct{ path string }
type syncFinishedMsg struct{ result syncResult }
type tuiModel struct {
root string
repos []repo
selected map[string]bool
opts options
cursor int
offset int
width int
height int
mode tuiMode
frame int
status string
lastResult []syncResult
syncEvents <-chan tea.Msg
syncTotal int
syncDone int
syncOK int
syncFailed int
syncSkipped int
syncActive map[string]bool
syncRecent []syncResult
syncStarted time.Time
}
func runTUI(root string, repos []repo, opts options) error {
m := newTUIModel(root, repos, opts)
program := tea.NewProgram(m, tea.WithAltScreen())
_, err := program.Run()
return err
}
func newTUIModel(root string, repos []repo, opts options) tuiModel {
selected := make(map[string]bool)
for _, r := range repos {
selected[r.Path] = r.Eligible
}
return tuiModel{root: root, repos: repos, selected: selected, opts: opts, mode: modeReady}
}
func (m tuiModel) Init() tea.Cmd { return nil }
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.clampViewport()
return m, nil
case tickMsg:
if m.mode == modeSyncing {
m.frame++
return m, tickCmd()
}
return m, nil
case scanDoneMsg:
if msg.err != nil {
m.status = "Refresh failed: " + msg.err.Error()
return m, nil
}
m.repos = msg.repos
m.reconcileSelection()
m.status = "Workspace refreshed"
m.clampViewport()
return m, nil
case syncStartedMsg:
m.syncActive[msg.path] = true
return m, waitSyncEventCmd(m.syncEvents)
case syncFinishedMsg:
delete(m.syncActive, msg.result.Path)
m.syncDone++
switch {
case msg.result.Success:
m.syncOK++
case msg.result.Skipped:
m.syncSkipped++
default:
m.syncFailed++
}
m.syncRecent = append(m.syncRecent, msg.result)
if len(m.syncRecent) > 4 {
m.syncRecent = m.syncRecent[len(m.syncRecent)-4:]
}
return m, waitSyncEventCmd(m.syncEvents)
case syncDoneMsg:
m.mode = modeReady
m.syncEvents = nil
m.lastResult = msg.results
if msg.err != nil {
m.status = "Sync failed: " + msg.err.Error()
return m, nil
}
m.repos = msg.repos
m.reconcileSelection()
var ok, failed, skipped int
for _, result := range msg.results {
switch {
case result.Success:
ok++
case result.Skipped:
skipped++
default:
failed++
}
}
m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped)
if path := firstAttentionPath(msg.results); path != "" {
m.focusPath(path)
m.status += "; focused first issue"
}
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
value := key.String()
if m.mode == modeSyncing {
if value == "q" || value == "ctrl+c" {
m.status = "Sync is running; wait for it to finish before quitting"
}
return m, nil
}
if m.mode == modeConfirm {
switch value {
case "y", "Y":
selected := m.selectedRepos()
m.mode = modeSyncing
m.status = ""
m.lastResult = nil
m.syncTotal = len(selected)
m.syncDone, m.syncOK, m.syncFailed, m.syncSkipped = 0, 0, 0, 0
m.syncActive = make(map[string]bool)
m.syncRecent = nil
m.syncStarted = time.Now()
events := make(chan tea.Msg, max(4, len(selected)*2+1))
m.syncEvents = events
return m, tea.Batch(m.startSyncCmd(selected, events), tickCmd())
case "n", "N", "esc", "q":
m.mode = modeReady
m.status = "Sync cancelled; nothing changed"
}
return m, nil
}
switch value {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor+1 < len(m.repos) {
m.cursor++
}
case " ":
if len(m.repos) > 0 && m.repos[m.cursor].Eligible {
path := m.repos[m.cursor].Path
m.selected[path] = !m.selected[path]
}
case "a":
for _, r := range m.repos {
if r.Eligible {
m.selected[r.Path] = true
}
}
m.status = "Selected every eligible repository"
case "n":
for path := range m.selected {
m.selected[path] = false
}
m.status = "Selection cleared"
case "r":
m.status = "Refreshing workspace…"
return m, m.scanCmd()
case "f":
if !m.focusNextAttention() {
m.status = "The last run has no failures, skips, warnings, or protected branches"
}
case "s", "enter":
count := len(m.selectedRepos())
if count == 0 {
m.status = "Select at least one eligible repository"
} else {
m.mode = modeConfirm
}
}
m.clampViewport()
return m, nil
}
func (m tuiModel) View() string {
width := m.width
if width <= 0 {
width = 100
}
contentWidth := max(40, width-6)
ready, blocked, selected := eligibleCount(m.repos), blockedCount(m.repos), len(m.selectedRepos())
var b strings.Builder
b.WriteString(titleStyle.Render("STACK SYNC"))
b.WriteString(mutedStyle.Render(" workspace control deck"))
b.WriteString("\n")
b.WriteString(mutedStyle.Render(truncateMiddle(m.root, contentWidth)))
b.WriteString("\n\n")
b.WriteString(readyStyle.Render(fmt.Sprintf("● %d ready", ready)))
b.WriteString(" ")
b.WriteString(blockStyle.Render(fmt.Sprintf("● %d blocked", blocked)))
b.WriteString(" ")
b.WriteString(titleStyle.Render(fmt.Sprintf("◆ %d selected", selected)))
b.WriteString("\n\n")
visible := m.visibleRows()
nameWidth := max(12, min(42, contentWidth-38))
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %-3s %-*s %-16s %s", "", nameWidth, "REPOSITORY", "BRANCH", "STATE")))
b.WriteString("\n")
for i := visible.start; i < visible.end; i++ {
r := m.repos[i]
pointer := " "
if i == m.cursor {
pointer = "›"
}
check := "[ ]"
if !r.Eligible {
check = "[–]"
} else if m.selected[r.Path] {
check = "[×]"
}
state := readyStyle.Render("ready")
if !r.Eligible {
state = blockStyle.Render("blocked")
} else if len(r.Dirty) > 0 {
state = titleStyle.Render("ready (dirty)")
}
line := fmt.Sprintf("%s %-3s %-*s %-16s %s", pointer, check, nameWidth, truncateMiddle(r.RelativePath, nameWidth), truncateMiddle(r.Branch, 16), state)
if i == m.cursor {
line = lipgloss.NewStyle().Bold(true).Foreground(amber).Render(line)
}
b.WriteString(line)
b.WriteString("\n")
}
if visible.start > 0 || visible.end < len(m.repos) {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" showing %d–%d of %d", visible.start+1, visible.end, len(m.repos))))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.detailView(contentWidth))
b.WriteString("\n")
switch m.mode {
case modeConfirm:
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Synchronize branches in %d selected repositories?", selected)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render("Dirty checked-out branches are protected; merged branches with deleted upstreams may be removed. y confirm • n/esc cancel"))
case modeSyncing:
b.WriteString(m.syncProgressView(contentWidth))
default:
if m.status != "" {
b.WriteString(m.status)
b.WriteString("\n")
}
if len(m.lastResult) > 0 {
b.WriteString(m.lastRunView(contentWidth))
b.WriteString("\n")
}
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • f next issue • q quit"))
}
return panel.Width(contentWidth).Render(b.String())
}
type rowRange struct{ start, end int }
func (m tuiModel) visibleRows() rowRange {
reserved := 15
if m.mode == modeSyncing {
reserved = 20
} else if len(m.lastResult) > 0 {
reserved = 20
}
available := m.height - reserved
if available < 4 {
available = 4
}
end := min(len(m.repos), m.offset+available)
return rowRange{start: min(m.offset, end), end: end}
}
func (m *tuiModel) clampViewport() {
if len(m.repos) == 0 {
m.cursor, m.offset = 0, 0
return
}
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
reserved := 15
if m.mode == modeSyncing || len(m.lastResult) > 0 {
reserved = 20
}
available := max(4, m.height-reserved)
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+available {
m.offset = m.cursor - available + 1
}
}
func (m tuiModel) detailView(width int) string {
if len(m.repos) == 0 {
return mutedStyle.Render("No repositories found")
}
r := m.repos[m.cursor]
var lines []string
if r.Eligible {
state := readyStyle.Render("READY")
if len(r.Dirty) > 0 {
state = titleStyle.Render("READY · CHECKED-OUT BRANCH PROTECTED")
}
lines = append(lines, state+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
} else {
lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason)
}
for i, item := range r.Dirty {
if i == 3 {
lines = append(lines, mutedStyle.Render(fmt.Sprintf("… and %d more changes", len(r.Dirty)-3)))
break
}
lines = append(lines, " "+truncateMiddle(item, width-4))
}
if len(r.NestedRepoEntries) > 0 {
lines = append(lines, mutedStyle.Render(fmt.Sprintf("%d nested repository path(s) safely excluded from parent dirtiness", len(r.NestedRepoEntries))))
}
for _, result := range m.lastResult {
if result.Path != r.RelativePath || result.Message == "" {
continue
}
state := readyStyle.Render("LAST SYNC")
if !result.Success {
state = blockStyle.Render("LAST SYNC FAILED")
}
lines = append(lines, state+mutedStyle.Render(" "+result.Duration))
output := strings.Split(strings.ReplaceAll(result.Message, "\r", "\n"), "\n")
shown := 0
for _, outputLine := range output {
if strings.TrimSpace(outputLine) == "" {
continue
}
if shown == 3 {
lines = append(lines, mutedStyle.Render(" … more output available in the CLI/JSON interface"))
break
}
lines = append(lines, " "+truncateMiddle(strings.TrimSpace(outputLine), width-4))
shown++
}
break
}
return strings.Join(lines, "\n")
}
func (m tuiModel) selectedRepos() []repo {
selected := make([]repo, 0)
for _, r := range m.repos {
if r.Eligible && m.selected[r.Path] {
selected = append(selected, r)
}
}
return selected
}
func (m *tuiModel) reconcileSelection() {
next := make(map[string]bool)
for _, r := range m.repos {
next[r.Path] = r.Eligible && m.selected[r.Path]
}
m.selected = next
}
func (m tuiModel) scanCmd() tea.Cmd {
return func() tea.Msg {
paths, err := discover(m.root, m.opts.exclusions)
if err != nil {
return scanDoneMsg{err: err}
}
return scanDoneMsg{repos: inspectAll(m.root, paths, m.opts.jobs)}
}
}
func (m tuiModel) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd {
return func() tea.Msg {
go func() {
results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, func(event syncProgressEvent) {
if event.Started {
events <- syncStartedMsg{path: event.Path}
} else {
events <- syncFinishedMsg{result: event.Result}
}
})
paths, err := discover(m.root, m.opts.exclusions)
if err != nil {
events <- syncDoneMsg{results: results, err: err}
return
}
events <- syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
}()
return <-events
}
}
func waitSyncEventCmd(events <-chan tea.Msg) tea.Cmd {
if events == nil {
return nil
}
return func() tea.Msg { return <-events }
}
func (m tuiModel) syncProgressView(width int) string {
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
phase := "Synchronizing repositories"
if m.syncDone == m.syncTotal && m.syncTotal > 0 {
phase = "Refreshing workspace"
}
elapsed := time.Since(m.syncStarted).Round(time.Second)
if elapsed < 0 {
elapsed = 0
}
barWidth := max(12, min(32, width-30))
filled := 0
if m.syncTotal > 0 {
filled = barWidth * m.syncDone / m.syncTotal
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
var lines []string
lines = append(lines, titleStyle.Render(fmt.Sprintf("%s %s", spinner, phase))+mutedStyle.Render(" "+elapsed.String()))
lines = append(lines, readyStyle.Render(bar)+fmt.Sprintf(" %d/%d", m.syncDone, m.syncTotal))
lines = append(lines, fmt.Sprintf("%s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", m.syncOK)), blockStyle.Render(fmt.Sprintf("%d failed", m.syncFailed)), mutedStyle.Render(fmt.Sprintf("%d skipped", m.syncSkipped))))
if len(m.syncActive) > 0 {
active := make([]string, 0, len(m.syncActive))
for path := range m.syncActive {
active = append(active, path)
}
sort.Strings(active)
lines = append(lines, mutedStyle.Render("Active: ")+truncateMiddle(strings.Join(active, ", "), max(10, width-8)))
}
if len(m.syncRecent) > 0 {
recent := m.syncRecent[len(m.syncRecent)-1]
label := readyStyle.Render("Latest ✓")
if recent.Skipped {
label = titleStyle.Render("Latest ↷")
} else if !recent.Success {
label = blockStyle.Render("Latest ✗")
}
lines = append(lines, label+" "+truncateMiddle(recent.Path+resultHeadline(recent), max(10, width-12)))
}
return strings.Join(lines, "\n")
}
func (m tuiModel) lastRunView(width int) string {
var ok, failed, skipped, protected, warnings int
var critical, caution []string
for _, result := range m.lastResult {
switch {
case result.Success:
ok++
case result.Skipped:
skipped++
critical = append(critical, "↷ "+result.Path+": "+firstLine(result.Message))
default:
failed++
critical = append(critical, "✗ "+result.Path+": "+firstLine(result.Message))
}
for _, branch := range result.Branches {
switch branch.Action {
case "protected":
protected++
caution = append(caution, "◆ "+result.Path+" / "+branch.Branch+": "+branch.Message)
case "warning":
warnings++
caution = append(caution, "! "+result.Path+" / "+branch.Branch+": "+branch.Message)
}
}
}
details := append(critical, caution...)
header := titleStyle.Render("LAST RUN") + fmt.Sprintf(" %s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", ok)), blockStyle.Render(fmt.Sprintf("%d failed", failed)), mutedStyle.Render(fmt.Sprintf("%d skipped", skipped)))
if protected+warnings > 0 {
header += titleStyle.Render(fmt.Sprintf(" %d protected · %d warnings", protected, warnings))
}
lines := []string{header}
shown := min(3, len(details))
for _, detail := range details[:shown] {
lines = append(lines, " "+truncateMiddle(detail, max(10, width-2)))
}
if len(details) > shown {
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … %d more; press f to cycle through issues", len(details)-shown)))
}
return strings.Join(lines, "\n")
}
func resultHeadline(result syncResult) string {
if result.Success && len(result.Branches) == 0 {
return " · up to date"
}
if result.Message == "" {
return ""
}
return " · " + firstLine(result.Message)
}
func resultNeedsAttention(result syncResult) bool {
if !result.Success || result.Skipped {
return true
}
for _, branch := range result.Branches {
if branch.Action == "warning" || branch.Action == "protected" {
return true
}
}
return false
}
func firstAttentionPath(results []syncResult) string {
for _, result := range results {
if !result.Success && !result.Skipped {
return result.Path
}
}
for _, result := range results {
if result.Skipped {
return result.Path
}
}
for _, result := range results {
for _, branch := range result.Branches {
if branch.Action == "warning" || branch.Action == "protected" {
return result.Path
}
}
}
return ""
}
func (m *tuiModel) focusPath(path string) bool {
for i, repo := range m.repos {
if repo.RelativePath == path {
m.cursor = i
m.clampViewport()
return true
}
}
return false
}
func (m *tuiModel) focusNextAttention() bool {
var paths []string
for _, result := range m.lastResult {
if resultNeedsAttention(result) {
paths = append(paths, result.Path)
}
}
if len(paths) == 0 {
return false
}
current := ""
if len(m.repos) > 0 {
current = m.repos[m.cursor].RelativePath
}
next := paths[0]
for i, path := range paths {
if path == current {
next = paths[(i+1)%len(paths)]
break
}
}
m.focusPath(next)
m.status = "Focused issue in " + next
return true
}
func tickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func truncateMiddle(value string, width int) string {
if width < 2 || len(value) <= width {
return value
}
left := (width - 1) / 2
right := width - left - 1
return value[:left] + "…" + value[len(value)-right:]
}