Files
stack-sync/tui.go
T
LyAhn bf67f1a005 feat(sync): remove the hub dependency
- add sync.go to fetch and prune the selected upstream, github, origin, or sole remote
- fast-forward the current branch with merge --ff-only and inactive branches with update-ref
- protect a dirty current branch and branches checked out in linked worktrees
- warn without changing divergent branches
- delete gone-upstream branches only after confirming they are merged into the remote default branch
- allow dirty repositories by default and retain the all-clean policy behind --strict
- return per-branch results in CLI, JSON, and TUI output
- add bare-remote integration tests, the hub MIT notice, and version 0.3.0
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:]
}