feat(tui): add interactive repository selection

Introduce a Bubble Tea control deck with eligible repositories preselected,
keyboard navigation, responsive paging, and detailed explanations for blocked
worktrees.

Require confirmation before synchronization, allow refreshing the workspace,
and retain each repository's latest hub output without changing the existing
CLI and JSON workflows.
This commit is contained in:
2026-09-06 18:34:05 +01:00
parent 295d694d78
commit 9bcaab4dc0
6 changed files with 569 additions and 6 deletions
+395
View File
@@ -0,0 +1,395 @@
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:]
}