From 8e86b7eadb632b997385bb84087bcac1d3db35a5 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 6 Sep 2026 20:19:05 +0100 Subject: [PATCH] feat: stream detailed TUI sync progress Show live repository progress, elapsed time, active work, and recent outcomes during long syncs. Add a persistent post-run summary that prioritizes failures, reports protected and divergent branches, focuses the first issue, and lets users cycle through attention items with f. --- .gitignore | 1 + README.md | 4 +- main.go | 29 ++++- main_test.go | 8 +- tui.go | 296 ++++++++++++++++++++++++++++++++++++++++++++++----- tui_test.go | 71 ++++++++++++ 6 files changed, 374 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 76fffb4..e607335 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /stack-sync /stack-sync.exe +/stack-sync.exe~ diff --git a/README.md b/README.md index e892dc1..e9a93ec 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,9 @@ PowerShell accepts the same options with a Windows path: stack-sync tui --root C:\Users\you\Coding\jwtf ``` -Every eligible repository starts selected. Dirty repositories remain selectable and are labelled `ready (dirty)`; their checked-out branches are protected if an update would affect them. Move with the arrow keys or `j`/`k`, toggle the focused repository with Space, select all with `a`, clear the selection with `n`, and refresh with `r`. Press `s` or Enter to review the branch-deletion warning, then `y` to begin. Blocked repositories cannot be selected. After a run, focusing a repository shows its latest per-branch results. +Every eligible repository starts selected. Dirty repositories remain selectable and are labelled `ready (dirty)`; their checked-out branches are protected if an update would affect them. Move with the arrow keys or `j`/`k`, toggle the focused repository with Space, select all with `a`, clear the selection with `n`, and refresh with `r`. Press `s` or Enter to review the branch-deletion warning, then `y` to begin. Blocked repositories cannot be selected. + +During synchronization, the TUI shows overall progress, elapsed time, active repositories, live success/failure/skip totals, and the latest completed result. The final view lists failures and protected or divergent branches, prioritizes and focuses the first failure automatically, and retains per-repository branch details. Press `f` to cycle through every repository that needs attention. ### CLI and automation diff --git a/main.go b/main.go index 6874809..55ffb9f 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ import ( "time" ) -const version = "0.3.0" +const version = "0.3.1" var defaultSkippedDirs = map[string]bool{ ".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true, @@ -64,6 +64,12 @@ type syncResult struct { Branches []branchSyncResult `json:"branches,omitempty"` } +type syncProgressEvent struct { + Path string + Started bool + Result syncResult +} + func main() { code := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr) os.Exit(code) @@ -421,6 +427,10 @@ func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout } func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool) []syncResult { + return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, nil) +} + +func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool, progress func(syncProgressEvent)) []syncResult { type item struct { index int repo repo @@ -433,6 +443,12 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t for _, r := range workspaceRepos { allPaths = append(allPaths, r.Path) } + record := func(index int, result syncResult) { + results[index] = result + if progress != nil { + progress(syncProgressEvent{Path: result.Path, Result: result}) + } + } for range min(jobs, len(repos)) { wg.Add(1) @@ -441,19 +457,22 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t for next := range work { r := next.repo if !r.Eligible { - results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason} + record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason}) continue } // Close the scan/apply race: inspect again immediately before mutation. fresh := inspect(root, r.Path, allPaths) if !fresh.Eligible { - results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason} + record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason}) continue } if strict && len(fresh.Dirty) > 0 { - results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: fmt.Sprintf("pre-sync recheck: dirty worktree (%d changes)", len(fresh.Dirty))} + record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: fmt.Sprintf("pre-sync recheck: dirty worktree (%d changes)", len(fresh.Dirty))}) continue } + if progress != nil { + progress(syncProgressEvent{Path: r.RelativePath, Started: true}) + } if !quiet { outputMu.Lock() fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch) @@ -478,7 +497,7 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t if err != nil && message == "" { message = err.Error() } - results[next.index] = syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration, Branches: report.Branches} + record(next.index, syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration, Branches: report.Branches}) } }() } diff --git a/main_test.go b/main_test.go index a8acb9e..628cdf2 100644 --- a/main_test.go +++ b/main_test.go @@ -123,10 +123,16 @@ func TestStrictSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) { t.Fatal(err) } - results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true, true) + var progress []syncProgressEvent + results := syncSelectedWithProgress(root, []repo{planned}, []repo{planned}, 1, time.Minute, io.Discard, true, true, func(event syncProgressEvent) { + progress = append(progress, event) + }) if len(results) != 1 || !results[0].Skipped || results[0].Success { t.Fatalf("syncAll() = %+v", results) } + if len(progress) != 1 || progress[0].Started || !progress[0].Result.Skipped { + t.Fatalf("progress events = %+v, want one finished skip", progress) + } } func TestScanReportsDirtyEligibleRepository(t *testing.T) { diff --git a/tui.go b/tui.go index 08b3ab9..1b74803 100644 --- a/tui.go +++ b/tui.go @@ -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) }) } diff --git a/tui_test.go b/tui_test.go index 92115bb..8e3298c 100644 --- a/tui_test.go +++ b/tui_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -78,6 +79,76 @@ func TestTUIViewShowsLastSyncOutputForFocusedRepository(t *testing.T) { } } +func TestTUISyncProgressShowsLiveCountsAndLatestResult(t *testing.T) { + events := make(chan tea.Msg, 4) + m := newTUIModel("/workspace", testTUIRepos(), options{}) + m.width, m.height = 100, 30 + m.mode = modeSyncing + m.syncEvents = events + m.syncTotal = 3 + m.syncActive = make(map[string]bool) + m.syncStarted = time.Now().Add(-5 * time.Second) + + updated, wait := m.Update(syncStartedMsg{path: "clean"}) + m = updated.(tuiModel) + if wait == nil || !m.syncActive["clean"] { + t.Fatalf("start event did not register an active repository: %+v", m.syncActive) + } + updated, wait = m.Update(syncFinishedMsg{result: syncResult{Path: "clean", Success: true, Message: "Already up to date."}}) + m = updated.(tuiModel) + if wait == nil || m.syncDone != 1 || m.syncOK != 1 || m.syncActive["clean"] { + t.Fatalf("finish event did not update progress: done=%d ok=%d active=%+v", m.syncDone, m.syncOK, m.syncActive) + } + view := m.View() + for _, want := range []string{"1/3", "1 synced", "0 failed", "Latest", "clean", "up to date"} { + if !strings.Contains(view, want) { + t.Fatalf("progress view missing %q:\n%s", want, view) + } + } +} + +func TestTUICompletionSummarizesAndFocusesFirstIssue(t *testing.T) { + m := newTUIModel("/workspace", testTUIRepos(), options{}) + m.width, m.height = 100, 30 + m.mode = modeSyncing + results := []syncResult{ + {Path: "clean", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "protected", Message: "checked out elsewhere"}}}, + {Path: "dirty", Message: "fetch origin: authentication failed"}, + } + + updated, _ := m.Update(syncDoneMsg{results: results, repos: testTUIRepos()}) + m = updated.(tuiModel) + if m.mode != modeReady || m.cursor != 1 || !strings.Contains(m.status, "focused first issue") { + t.Fatalf("completion did not focus the first issue: mode=%v cursor=%d status=%q", m.mode, m.cursor, m.status) + } + view := m.View() + for _, want := range []string{"LAST RUN", "1 synced", "1 failed", "dirty", "authentication failed", "f next"} { + if !strings.Contains(view, want) { + t.Fatalf("completion view missing %q:\n%s", want, view) + } + } +} + +func TestTUIFocusNextIssueCyclesThroughAttentionResults(t *testing.T) { + m := newTUIModel("/workspace", testTUIRepos(), options{}) + m.lastResult = []syncResult{ + {Path: "clean", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "protected", Message: "checked out elsewhere"}}}, + {Path: "dirty", Message: "fetch failed"}, + } + m.cursor = 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + m = updated.(tuiModel) + if got := m.repos[m.cursor].RelativePath; got != "dirty" { + t.Fatalf("focused repository = %q, want dirty", got) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + m = updated.(tuiModel) + if got := m.repos[m.cursor].RelativePath; got != "clean" { + t.Fatalf("focused repository = %q, want clean after cycling", got) + } +} + func TestTruncateMiddle(t *testing.T) { if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" { t.Fatalf("truncateMiddle() = %q", got)