package main import ( "fmt" "io" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var ( amber = lipgloss.Color("214") green = lipgloss.Color("42") red = lipgloss.Color("196") muted = lipgloss.Color("241") panel = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("238")).Padding(0, 1) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(amber) readyStyle = lipgloss.NewStyle().Foreground(green) blockStyle = lipgloss.NewStyle().Foreground(red) mutedStyle = lipgloss.NewStyle().Foreground(muted) ) type tuiMode int const ( modeReady tuiMode = iota modeConfirm modeSyncing ) type tickMsg time.Time type scanDoneMsg struct { repos []repo err error } type syncDoneMsg struct { results []syncResult 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 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 { m := newTUIModel(root, repos, opts) program := tea.NewProgram(m, tea.WithAltScreen()) _, err := program.Run() return err } func newTUIModel(root string, repos []repo, opts options) tuiModel { selected := make(map[string]bool) for _, r := range repos { selected[r.Path] = r.Eligible } return tuiModel{root: root, repos: repos, selected: selected, opts: opts, mode: modeReady} } func (m tuiModel) Init() tea.Cmd { return nil } func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.clampViewport() return m, nil case tickMsg: if m.mode == modeSyncing { m.frame++ return m, tickCmd() } return m, nil case scanDoneMsg: if msg.err != nil { m.status = "Refresh failed: " + msg.err.Error() return m, nil } m.repos = msg.repos m.reconcileSelection() 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() return m, nil } m.repos = msg.repos m.reconcileSelection() var ok, failed, skipped int for _, result := range msg.results { switch { case result.Success: ok++ case result.Skipped: skipped++ default: failed++ } } 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) } return m, nil } func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { value := key.String() if m.mode == modeSyncing { if value == "q" || value == "ctrl+c" { m.status = "Sync is running; wait for it to finish before quitting" } return m, nil } if m.mode == modeConfirm { switch value { case "y", "Y": selected := m.selectedRepos() m.mode = modeSyncing 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" } return m, nil } switch value { case "q", "ctrl+c": return m, tea.Quit case "up", "k": if m.cursor > 0 { m.cursor-- } case "down", "j": if m.cursor+1 < len(m.repos) { m.cursor++ } case " ": if len(m.repos) > 0 && m.repos[m.cursor].Eligible { path := m.repos[m.cursor].Path m.selected[path] = !m.selected[path] } case "a": for _, r := range m.repos { if r.Eligible { m.selected[r.Path] = true } } m.status = "Selected every eligible repository" case "n": for path := range m.selected { m.selected[path] = false } m.status = "Selection cleared" 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 { m.status = "Select at least one eligible repository" } else { m.mode = modeConfirm } } m.clampViewport() return m, nil } func (m tuiModel) View() string { width := m.width if width <= 0 { width = 100 } contentWidth := max(40, width-6) ready, blocked, selected := eligibleCount(m.repos), blockedCount(m.repos), len(m.selectedRepos()) var b strings.Builder b.WriteString(titleStyle.Render("STACK SYNC")) b.WriteString(mutedStyle.Render(" workspace control deck")) b.WriteString("\n") b.WriteString(mutedStyle.Render(truncateMiddle(m.root, contentWidth))) b.WriteString("\n\n") b.WriteString(readyStyle.Render(fmt.Sprintf("● %d ready", ready))) b.WriteString(" ") b.WriteString(blockStyle.Render(fmt.Sprintf("● %d blocked", blocked))) b.WriteString(" ") b.WriteString(titleStyle.Render(fmt.Sprintf("◆ %d selected", selected))) b.WriteString("\n\n") visible := m.visibleRows() nameWidth := max(12, min(42, contentWidth-38)) b.WriteString(mutedStyle.Render(fmt.Sprintf(" %-3s %-*s %-16s %s", "", nameWidth, "REPOSITORY", "BRANCH", "STATE"))) b.WriteString("\n") for i := visible.start; i < visible.end; i++ { r := m.repos[i] pointer := " " if i == m.cursor { pointer = "›" } check := "[ ]" if !r.Eligible { check = "[–]" } else if m.selected[r.Path] { check = "[×]" } state := readyStyle.Render("ready") if !r.Eligible { state = blockStyle.Render("blocked") } else if len(r.Dirty) > 0 { state = titleStyle.Render("ready (dirty)") } line := fmt.Sprintf("%s %-3s %-*s %-16s %s", pointer, check, nameWidth, truncateMiddle(r.RelativePath, nameWidth), truncateMiddle(r.Branch, 16), state) if i == m.cursor { line = lipgloss.NewStyle().Bold(true).Foreground(amber).Render(line) } b.WriteString(line) b.WriteString("\n") } if visible.start > 0 || visible.end < len(m.repos) { b.WriteString(mutedStyle.Render(fmt.Sprintf(" showing %d–%d of %d", visible.start+1, visible.end, len(m.repos)))) b.WriteString("\n") } b.WriteString("\n") b.WriteString(m.detailView(contentWidth)) b.WriteString("\n") switch m.mode { case modeConfirm: b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Synchronize branches in %d selected repositories?", selected))) 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: b.WriteString(m.syncProgressView(contentWidth)) default: if m.status != "" { b.WriteString(m.status) b.WriteString("\n") } 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()) } type rowRange struct{ start, end int } func (m tuiModel) visibleRows() rowRange { 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 } end := min(len(m.repos), m.offset+available) return rowRange{start: min(m.offset, end), end: end} } func (m *tuiModel) clampViewport() { if len(m.repos) == 0 { m.cursor, m.offset = 0, 0 return } m.cursor = max(0, min(m.cursor, len(m.repos)-1)) 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 } if m.cursor >= m.offset+available { m.offset = m.cursor - available + 1 } } func (m tuiModel) detailView(width int) string { if len(m.repos) == 0 { return mutedStyle.Render("No repositories found") } r := m.repos[m.cursor] var lines []string if r.Eligible { state := readyStyle.Render("READY") if len(r.Dirty) > 0 { state = titleStyle.Render("READY · CHECKED-OUT BRANCH PROTECTED") } lines = append(lines, state+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch)) } else { lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason) } for i, item := range r.Dirty { if i == 3 { lines = append(lines, mutedStyle.Render(fmt.Sprintf("… and %d more changes", len(r.Dirty)-3))) break } lines = append(lines, " "+truncateMiddle(item, width-4)) } if len(r.NestedRepoEntries) > 0 { lines = append(lines, mutedStyle.Render(fmt.Sprintf("%d nested repository path(s) safely excluded from parent dirtiness", len(r.NestedRepoEntries)))) } for _, result := range m.lastResult { if result.Path != r.RelativePath || result.Message == "" { continue } state := readyStyle.Render("LAST SYNC") if !result.Success { state = blockStyle.Render("LAST SYNC FAILED") } lines = append(lines, state+mutedStyle.Render(" "+result.Duration)) output := strings.Split(strings.ReplaceAll(result.Message, "\r", "\n"), "\n") shown := 0 for _, outputLine := range output { if strings.TrimSpace(outputLine) == "" { continue } if shown == 3 { lines = append(lines, mutedStyle.Render(" … more output available in the CLI/JSON interface")) break } lines = append(lines, " "+truncateMiddle(strings.TrimSpace(outputLine), width-4)) shown++ } break } return strings.Join(lines, "\n") } func (m tuiModel) selectedRepos() []repo { selected := make([]repo, 0) for _, r := range m.repos { if r.Eligible && m.selected[r.Path] { selected = append(selected, r) } } return selected } func (m *tuiModel) reconcileSelection() { next := make(map[string]bool) for _, r := range m.repos { next[r.Path] = r.Eligible && m.selected[r.Path] } m.selected = next } func (m tuiModel) scanCmd() tea.Cmd { return func() tea.Msg { paths, err := discover(m.root, m.opts.exclusions) if err != nil { return scanDoneMsg{err: err} } return scanDoneMsg{repos: inspectAll(m.root, paths, m.opts.jobs)} } } func (m tuiModel) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd { return func() tea.Msg { 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) }) } func truncateMiddle(value string, width int) string { if width < 2 || len(value) <= width { return value } left := (width - 1) / 2 right := width - left - 1 return value[:left] + "…" + value[len(value)-right:] }