e397da2733
- 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
396 lines
10 KiB
Go
396 lines
10 KiB
Go
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:]
|
||
}
|