package main import ( "fmt" "io" "os/exec" "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 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 } 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 syncDoneMsg: m.mode = modeReady 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) 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 = fmt.Sprintf("Syncing %d repositories", len(selected)) return m, tea.Batch(m.syncCmd(selected), 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 "s", "enter": count := len(m.selectedRepos()) if count == 0 { m.status = "Select at least one eligible repository" } else if _, err := exec.LookPath("hub"); err != nil { m.status = "hub is required for sync but was not found on PATH" } 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") } 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("Run hub sync in %d selected repositories?", selected))) b.WriteString("\n") b.WriteString(mutedStyle.Render("hub may delete local branches it considers merged. 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.")) 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")) } return panel.Width(contentWidth).Render(b.String()) } type rowRange struct{ start, end int } func (m tuiModel) visibleRows() rowRange { available := m.height - 15 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)) available := max(4, m.height-15) 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 { lines = append(lines, readyStyle.Render("READY")+" "+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) syncCmd(selected []repo) tea.Cmd { return func() tea.Msg { results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true) 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)} } } 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:] }