feat(tui): add repository picker

- add the tui command using Bubble Tea and Lip Gloss
- preselect eligible repositories and disable selection for blocked repositories
- add keyboard controls for navigation, selection, refresh, sync, and cancellation
- require confirmation before sync and show the branch-deletion warning
- show dirty paths, block reasons, and hub output for the focused repository
- add TUI model tests and set the embedded version to 0.2.0
This commit is contained in:
2026-09-06 18:34:05 +01:00
parent 717ae0addb
commit e397da2733
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:]
}