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.
This commit is contained in:
2026-09-06 20:19:05 +01:00
parent a980153b3e
commit 8e86b7eadb
6 changed files with 374 additions and 35 deletions
+1
View File
@@ -1,2 +1,3 @@
/stack-sync /stack-sync
/stack-sync.exe /stack-sync.exe
/stack-sync.exe~
+3 -1
View File
@@ -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
+24 -5
View File
@@ -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
View File
@@ -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) {
+268 -28
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"sort"
"strings" "strings"
"time" "time"
@@ -40,20 +41,31 @@ 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
repos []repo repos []repo
selected map[string]bool selected map[string]bool
opts options opts options
cursor int cursor int
offset int offset int
width int width int
height int height int
mode tuiMode mode tuiMode
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() {
paths, err := discover(m.root, m.opts.exclusions) results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, func(event syncProgressEvent) {
if err != nil { if event.Started {
return syncDoneMsg{results: results, err: err} events <- syncStartedMsg{path: event.Path}
} } else {
return syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)} 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 { 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
View File
@@ -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)