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:
@@ -1,2 +1,3 @@
|
|||||||
/stack-sync
|
/stack-sync
|
||||||
/stack-sync.exe
|
/stack-sync.exe
|
||||||
|
/stack-sync.exe~
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ PowerShell accepts the same options with a Windows path:
|
|||||||
stack-sync tui --root C:\Users\you\Coding\jwtf
|
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
|
### CLI and automation
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.3.0"
|
const version = "0.3.1"
|
||||||
|
|
||||||
var defaultSkippedDirs = map[string]bool{
|
var defaultSkippedDirs = map[string]bool{
|
||||||
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
|
".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"`
|
Branches []branchSyncResult `json:"branches,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type syncProgressEvent struct {
|
||||||
|
Path string
|
||||||
|
Started bool
|
||||||
|
Result syncResult
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
code := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
|
code := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
|
||||||
os.Exit(code)
|
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 {
|
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 {
|
type item struct {
|
||||||
index int
|
index int
|
||||||
repo repo
|
repo repo
|
||||||
@@ -433,6 +443,12 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
|
|||||||
for _, r := range workspaceRepos {
|
for _, r := range workspaceRepos {
|
||||||
allPaths = append(allPaths, r.Path)
|
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)) {
|
for range min(jobs, len(repos)) {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -441,19 +457,22 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
|
|||||||
for next := range work {
|
for next := range work {
|
||||||
r := next.repo
|
r := next.repo
|
||||||
if !r.Eligible {
|
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
|
continue
|
||||||
}
|
}
|
||||||
// Close the scan/apply race: inspect again immediately before mutation.
|
// Close the scan/apply race: inspect again immediately before mutation.
|
||||||
fresh := inspect(root, r.Path, allPaths)
|
fresh := inspect(root, r.Path, allPaths)
|
||||||
if !fresh.Eligible {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if strict && len(fresh.Dirty) > 0 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress(syncProgressEvent{Path: r.RelativePath, Started: true})
|
||||||
|
}
|
||||||
if !quiet {
|
if !quiet {
|
||||||
outputMu.Lock()
|
outputMu.Lock()
|
||||||
fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch)
|
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 == "" {
|
if err != nil && message == "" {
|
||||||
message = err.Error()
|
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})
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -123,10 +123,16 @@ func TestStrictSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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 {
|
if len(results) != 1 || !results[0].Skipped || results[0].Success {
|
||||||
t.Fatalf("syncAll() = %+v", results)
|
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) {
|
func TestScanReportsDirtyEligibleRepository(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ type syncDoneMsg struct {
|
|||||||
repos []repo
|
repos []repo
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
type syncStartedMsg struct{ path string }
|
||||||
|
type syncFinishedMsg struct{ result syncResult }
|
||||||
|
|
||||||
type tuiModel struct {
|
type tuiModel struct {
|
||||||
root string
|
root string
|
||||||
@@ -54,6 +57,15 @@ type tuiModel struct {
|
|||||||
frame int
|
frame int
|
||||||
status string
|
status string
|
||||||
lastResult []syncResult
|
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 {
|
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.status = "Workspace refreshed"
|
||||||
m.clampViewport()
|
m.clampViewport()
|
||||||
return m, nil
|
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:
|
case syncDoneMsg:
|
||||||
m.mode = modeReady
|
m.mode = modeReady
|
||||||
|
m.syncEvents = nil
|
||||||
m.lastResult = msg.results
|
m.lastResult = msg.results
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.status = "Sync failed: " + msg.err.Error()
|
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)
|
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
|
return m, nil
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
return m.handleKey(msg)
|
return m.handleKey(msg)
|
||||||
@@ -136,8 +172,16 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "y", "Y":
|
case "y", "Y":
|
||||||
selected := m.selectedRepos()
|
selected := m.selectedRepos()
|
||||||
m.mode = modeSyncing
|
m.mode = modeSyncing
|
||||||
m.status = fmt.Sprintf("Syncing %d repositories", len(selected))
|
m.status = ""
|
||||||
return m, tea.Batch(m.syncCmd(selected), tickCmd())
|
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":
|
case "n", "N", "esc", "q":
|
||||||
m.mode = modeReady
|
m.mode = modeReady
|
||||||
m.status = "Sync cancelled; nothing changed"
|
m.status = "Sync cancelled; nothing changed"
|
||||||
@@ -176,6 +220,10 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "r":
|
case "r":
|
||||||
m.status = "Refreshing workspace…"
|
m.status = "Refreshing workspace…"
|
||||||
return m, m.scanCmd()
|
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":
|
case "s", "enter":
|
||||||
count := len(m.selectedRepos())
|
count := len(m.selectedRepos())
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -253,16 +301,17 @@ func (m tuiModel) View() string {
|
|||||||
b.WriteString("\n")
|
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"))
|
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:
|
case modeSyncing:
|
||||||
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
|
b.WriteString(m.syncProgressView(contentWidth))
|
||||||
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."))
|
|
||||||
default:
|
default:
|
||||||
if m.status != "" {
|
if m.status != "" {
|
||||||
b.WriteString(m.status)
|
b.WriteString(m.status)
|
||||||
b.WriteString("\n")
|
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())
|
return panel.Width(contentWidth).Render(b.String())
|
||||||
}
|
}
|
||||||
@@ -270,7 +319,13 @@ func (m tuiModel) View() string {
|
|||||||
type rowRange struct{ start, end int }
|
type rowRange struct{ start, end int }
|
||||||
|
|
||||||
func (m tuiModel) visibleRows() rowRange {
|
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 {
|
if available < 4 {
|
||||||
available = 4
|
available = 4
|
||||||
}
|
}
|
||||||
@@ -284,7 +339,11 @@ func (m *tuiModel) clampViewport() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
|
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 {
|
if m.cursor < m.offset {
|
||||||
m.offset = m.cursor
|
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 {
|
return func() tea.Msg {
|
||||||
results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false)
|
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)
|
paths, err := discover(m.root, m.opts.exclusions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return syncDoneMsg{results: results, err: err}
|
events <- syncDoneMsg{results: results, err: err}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
|
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 {
|
func tickCmd() tea.Cmd {
|
||||||
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
|
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||||
}
|
}
|
||||||
|
|||||||
+71
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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) {
|
func TestTruncateMiddle(t *testing.T) {
|
||||||
if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" {
|
if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" {
|
||||||
t.Fatalf("truncateMiddle() = %q", got)
|
t.Fatalf("truncateMiddle() = %q", got)
|
||||||
|
|||||||
Reference in New Issue
Block a user