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.
This commit is contained in:
2026-09-06 20:19:05 +01:00
parent b80713b334
commit dfc6218e45
6 changed files with 374 additions and 35 deletions
+268 -28
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"io"
"sort"
"strings"
"time"
@@ -40,20 +41,31 @@ type syncDoneMsg struct {
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
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 {
@@ -95,8 +107,28 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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()
@@ -116,6 +148,10 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
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)
@@ -136,8 +172,16 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "y", "Y":
selected := m.selectedRepos()
m.mode = modeSyncing
m.status = fmt.Sprintf("Syncing %d repositories", len(selected))
return m, tea.Batch(m.syncCmd(selected), tickCmd())
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"
@@ -176,6 +220,10 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
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 {
@@ -253,16 +301,17 @@ func (m tuiModel) View() string {
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:
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
b.WriteString(titleStyle.Render(fmt.Sprintf("%s Syncing %d repositories…", spinner, selected)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render("Safety is rechecked immediately before each repository. Please wait."))
b.WriteString(m.syncProgressView(contentWidth))
default:
if m.status != "" {
b.WriteString(m.status)
b.WriteString("\n")
}
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • q quit"))
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())
}
@@ -270,7 +319,13 @@ func (m tuiModel) View() string {
type rowRange struct{ start, end int }
func (m tuiModel) visibleRows() rowRange {
available := m.height - 15
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
}
@@ -284,7 +339,11 @@ func (m *tuiModel) clampViewport() {
return
}
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
available := max(4, m.height-15)
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
}
@@ -373,17 +432,198 @@ func (m tuiModel) scanCmd() tea.Cmd {
}
}
func (m tuiModel) syncCmd(selected []repo) tea.Cmd {
func (m tuiModel) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd {
return func() tea.Msg {
results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false)
paths, err := discover(m.root, m.opts.exclusions)
if err != nil {
return syncDoneMsg{results: results, err: err}
}
return syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
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) })
}