feat(tui): show per-repository sync progress
- emit start and finish events from the concurrent sync worker pool - show elapsed time, a progress bar, active repositories, result counts, and the latest result - retain failures, skips, protected branches, and divergence warnings after the run - focus the first failed repository and add f to cycle through repositories requiring attention - add progress and summary tests, document the new controls, and set version 0.3.1
This commit is contained in:
@@ -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) })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user