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:
@@ -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:]
|
||||
}
|
||||
Reference in New Issue
Block a user