Files
stack-sync/tui.go
T
LyAhn b80713b334 feat(sync): replace hub with an internal branch engine
Fetch and prune the preferred remote, then reconcile every local branch
independently. Fast-forward branches that are behind, preserve divergent work,
and delete a branch only when its configured upstream is gone and its commits
are already contained by the remote default branch.

Keep dirty repositories eligible while protecting the active branch and refs
checked out in linked worktrees. Preserve the previous all-clean policy behind
--strict, expose structured per-branch results, add real bare-remote integration
tests, and retain the upstream MIT notice for the adapted hub sync behavior.
2026-09-06 19:41:53 +01:00

399 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"io"
"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 {
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")
} else if len(r.Dirty) > 0 {
state = titleStyle.Render("ready (dirty)")
}
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("Synchronize branches in %d selected repositories?", selected)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render("Dirty checked-out branches are protected; merged branches with deleted upstreams may be removed. 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 {
state := readyStyle.Render("READY")
if len(r.Dirty) > 0 {
state = titleStyle.Render("READY · CHECKED-OUT BRANCH PROTECTED")
}
lines = append(lines, state+" "+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, false)
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:]
}