f4286edff2
hub sync silently dropped any local branch whose upstream it could not resolve: it cleared its internal remote branch while leaving the "gone" flag false, so neither the update nor the delete path ran. A run could then print "Already up to date" having done nothing, which is why a workspace-wide sync looked like it worked when it did not. - report unmatched branches as UNTRACKED with the reason, so "Already up to date" is only said when it is true - resolve the remote default branch via `git ls-remote --symref` when refs/remotes/<remote>/HEAD is missing, so merged branches whose upstream was deleted are still cleaned up - make --create-missing opt-in, keeping hub's default of never creating a local branch that does not already exist - summarise runs as changed/unchanged with per-action counts, and list deletions and failures in full instead of leaving them in scrollback - restore per-command flag help, which the custom Usage had dropped - add an in-place CLI progress line, drawn only to a terminal so piped and --json output stay free of cursor control - group the TUI selection screen by outcome so blocked and dirty repositories are visible before a run starts 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
742 lines
20 KiB
Go
742 lines
20 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"sort"
|
||
"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 syncStartedMsg struct{ path string }
|
||
type syncFinishedMsg struct{ result syncResult }
|
||
|
||
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
|
||
syncEvents <-chan tea.Msg
|
||
syncTotal int
|
||
syncDone int
|
||
syncOK int
|
||
syncFailed int
|
||
syncSkipped int
|
||
syncActive map[string]bool
|
||
syncRecent []syncResult
|
||
syncStarted time.Time
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// repoGroup is the outcome a repository will have if a run starts right now.
|
||
// The selection screen is grouped by it so problems are visible before the user
|
||
// commits to anything, rather than only after a run.
|
||
type repoGroup int
|
||
|
||
const (
|
||
groupBlocked repoGroup = iota
|
||
groupDirty
|
||
groupReady
|
||
)
|
||
|
||
func groupOf(r repo) repoGroup {
|
||
switch {
|
||
case !r.Eligible:
|
||
return groupBlocked
|
||
case len(r.Dirty) > 0:
|
||
return groupDirty
|
||
default:
|
||
return groupReady
|
||
}
|
||
}
|
||
|
||
func groupLabel(g repoGroup) string {
|
||
switch g {
|
||
case groupBlocked:
|
||
return "BLOCKED · not eligible, fix by hand"
|
||
case groupDirty:
|
||
return "DIRTY · checked-out branch protected"
|
||
default:
|
||
return "READY"
|
||
}
|
||
}
|
||
|
||
// sortReposByOutcome puts the repositories that need attention first, then
|
||
// groups equal outcomes together in a stable, name-ordered list.
|
||
func sortReposByOutcome(repos []repo) {
|
||
sort.SliceStable(repos, func(i, j int) bool {
|
||
if gi, gj := groupOf(repos[i]), groupOf(repos[j]); gi != gj {
|
||
return gi < gj
|
||
}
|
||
return repos[i].RelativePath < repos[j].RelativePath
|
||
})
|
||
}
|
||
|
||
func newTUIModel(root string, repos []repo, opts options) tuiModel {
|
||
sortReposByOutcome(repos)
|
||
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
|
||
sortReposByOutcome(m.repos)
|
||
m.reconcileSelection()
|
||
m.status = "Workspace refreshed"
|
||
m.clampViewport()
|
||
return m, nil
|
||
case syncStartedMsg:
|
||
m.syncActive[msg.path] = true
|
||
return m, waitSyncEventCmd(m.syncEvents)
|
||
case syncFinishedMsg:
|
||
delete(m.syncActive, msg.result.Path)
|
||
m.syncDone++
|
||
switch {
|
||
case msg.result.Success:
|
||
m.syncOK++
|
||
case msg.result.Skipped:
|
||
m.syncSkipped++
|
||
default:
|
||
m.syncFailed++
|
||
}
|
||
m.syncRecent = append(m.syncRecent, msg.result)
|
||
if len(m.syncRecent) > 4 {
|
||
m.syncRecent = m.syncRecent[len(m.syncRecent)-4:]
|
||
}
|
||
return m, waitSyncEventCmd(m.syncEvents)
|
||
case syncDoneMsg:
|
||
m.mode = modeReady
|
||
m.syncEvents = nil
|
||
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, unchanged, failed, skipped int
|
||
for _, result := range msg.results {
|
||
switch {
|
||
case result.Skipped:
|
||
skipped++
|
||
case !result.Success:
|
||
failed++
|
||
case repositoryChanged(result):
|
||
ok++
|
||
default:
|
||
unchanged++
|
||
}
|
||
}
|
||
m.status = fmt.Sprintf("Finished: %d changed, %d unchanged, %d failed, %d safety-skipped", ok, unchanged, failed, skipped)
|
||
if path := firstAttentionPath(msg.results); path != "" {
|
||
m.focusPath(path)
|
||
m.status += "; focused first issue"
|
||
}
|
||
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 = ""
|
||
m.lastResult = nil
|
||
m.syncTotal = len(selected)
|
||
m.syncDone, m.syncOK, m.syncFailed, m.syncSkipped = 0, 0, 0, 0
|
||
m.syncActive = make(map[string]bool)
|
||
m.syncRecent = nil
|
||
m.syncStarted = time.Now()
|
||
events := make(chan tea.Msg, max(4, len(selected)*2+1))
|
||
m.syncEvents = events
|
||
return m, tea.Batch(m.startSyncCmd(selected, events), 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 "f":
|
||
if !m.focusNextAttention() {
|
||
m.status = "The last run has no failures, skips, warnings, or protected branches"
|
||
}
|
||
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]
|
||
if i == 0 || groupOf(r) != groupOf(m.repos[i-1]) {
|
||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %s", groupLabel(groupOf(r)))))
|
||
b.WriteString("\n")
|
||
}
|
||
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:
|
||
b.WriteString(m.syncProgressView(contentWidth))
|
||
default:
|
||
if m.status != "" {
|
||
b.WriteString(m.status)
|
||
b.WriteString("\n")
|
||
}
|
||
if len(m.lastResult) > 0 {
|
||
b.WriteString(m.lastRunView(contentWidth))
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • f next issue • q quit"))
|
||
}
|
||
return panel.Width(contentWidth).Render(b.String())
|
||
}
|
||
|
||
type rowRange struct{ start, end int }
|
||
|
||
// listHeight is how many screen rows the repository list may occupy, leaving
|
||
// room for the header, detail pane, and footer.
|
||
func (m tuiModel) listHeight() int {
|
||
reserved := 15
|
||
if m.mode == modeSyncing || len(m.lastResult) > 0 {
|
||
reserved = 20
|
||
}
|
||
return max(4, m.height-reserved)
|
||
}
|
||
|
||
// groupHeaderRows counts the group headings rendered inside [start,end). A
|
||
// heading appears wherever the group changes, so scrolling into a new group
|
||
// labels it.
|
||
func (m tuiModel) groupHeaderRows(start, end int) int {
|
||
rows := 0
|
||
for i := max(0, start); i < end; i++ {
|
||
if i == 0 || groupOf(m.repos[i]) != groupOf(m.repos[i-1]) {
|
||
rows++
|
||
}
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// windowRows is the number of screen rows the slice occupies, headings included.
|
||
func (m tuiModel) windowRows(start, end int) int {
|
||
if start >= end {
|
||
return 0
|
||
}
|
||
return end - start + m.groupHeaderRows(start, end)
|
||
}
|
||
|
||
func (m tuiModel) visibleRows() rowRange {
|
||
available := m.listHeight()
|
||
start := min(m.offset, len(m.repos))
|
||
end := start
|
||
for end < len(m.repos) && m.windowRows(start, end+1) <= available {
|
||
end++
|
||
}
|
||
// Reclaiming rows above the offset keeps a short list from scrolling the
|
||
// top out of view when the offset no longer reflects a full window.
|
||
for start > 0 && m.windowRows(start-1, end) <= available {
|
||
start--
|
||
}
|
||
return rowRange{start: start, 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))
|
||
if m.offset > m.cursor {
|
||
m.offset = m.cursor
|
||
}
|
||
// Headings share the budget, so the window can be shorter than the row
|
||
// count alone suggests. Step forward until the cursor is on screen; the
|
||
// loop always terminates because an offset equal to the cursor renders it.
|
||
for m.offset < m.cursor {
|
||
if m.cursor < m.visibleRows().end {
|
||
break
|
||
}
|
||
m.offset++
|
||
}
|
||
}
|
||
|
||
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) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd {
|
||
return func() tea.Msg {
|
||
go func() {
|
||
results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, m.opts.createMissing, nil, func(event syncProgressEvent) {
|
||
if event.Started {
|
||
events <- syncStartedMsg{path: event.Path}
|
||
} else {
|
||
events <- syncFinishedMsg{result: event.Result}
|
||
}
|
||
})
|
||
paths, err := discover(m.root, m.opts.exclusions)
|
||
if err != nil {
|
||
events <- syncDoneMsg{results: results, err: err}
|
||
return
|
||
}
|
||
events <- syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
|
||
}()
|
||
return <-events
|
||
}
|
||
}
|
||
|
||
func waitSyncEventCmd(events <-chan tea.Msg) tea.Cmd {
|
||
if events == nil {
|
||
return nil
|
||
}
|
||
return func() tea.Msg { return <-events }
|
||
}
|
||
|
||
func (m tuiModel) syncProgressView(width int) string {
|
||
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
|
||
phase := "Synchronizing repositories"
|
||
if m.syncDone == m.syncTotal && m.syncTotal > 0 {
|
||
phase = "Refreshing workspace"
|
||
}
|
||
elapsed := time.Since(m.syncStarted).Round(time.Second)
|
||
if elapsed < 0 {
|
||
elapsed = 0
|
||
}
|
||
barWidth := max(12, min(32, width-30))
|
||
filled := 0
|
||
if m.syncTotal > 0 {
|
||
filled = barWidth * m.syncDone / m.syncTotal
|
||
}
|
||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||
|
||
var lines []string
|
||
lines = append(lines, titleStyle.Render(fmt.Sprintf("%s %s", spinner, phase))+mutedStyle.Render(" "+elapsed.String()))
|
||
lines = append(lines, readyStyle.Render(bar)+fmt.Sprintf(" %d/%d", m.syncDone, m.syncTotal))
|
||
lines = append(lines, fmt.Sprintf("%s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", m.syncOK)), blockStyle.Render(fmt.Sprintf("%d failed", m.syncFailed)), mutedStyle.Render(fmt.Sprintf("%d skipped", m.syncSkipped))))
|
||
if len(m.syncActive) > 0 {
|
||
active := make([]string, 0, len(m.syncActive))
|
||
for path := range m.syncActive {
|
||
active = append(active, path)
|
||
}
|
||
sort.Strings(active)
|
||
lines = append(lines, mutedStyle.Render("Active: ")+truncateMiddle(strings.Join(active, ", "), max(10, width-8)))
|
||
}
|
||
if len(m.syncRecent) > 0 {
|
||
recent := m.syncRecent[len(m.syncRecent)-1]
|
||
label := readyStyle.Render("Latest ✓")
|
||
if recent.Skipped {
|
||
label = titleStyle.Render("Latest ↷")
|
||
} else if !recent.Success {
|
||
label = blockStyle.Render("Latest ✗")
|
||
}
|
||
lines = append(lines, label+" "+truncateMiddle(recent.Path+resultHeadline(recent), max(10, width-12)))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (m tuiModel) lastRunView(width int) string {
|
||
var changed, unchanged, failed, skipped, protected, warnings, untracked, deleted, updated, created int
|
||
var critical, caution []string
|
||
for _, result := range m.lastResult {
|
||
switch {
|
||
case result.Skipped:
|
||
skipped++
|
||
critical = append(critical, "↷ "+result.Path+": "+firstLine(result.Message))
|
||
case !result.Success:
|
||
failed++
|
||
critical = append(critical, "✗ "+result.Path+": "+firstLine(result.Message))
|
||
case repositoryChanged(result):
|
||
changed++
|
||
default:
|
||
unchanged++
|
||
}
|
||
for _, branch := range result.Branches {
|
||
switch branch.Action {
|
||
case "updated":
|
||
updated++
|
||
case "created":
|
||
created++
|
||
case "deleted":
|
||
deleted++
|
||
critical = append(critical, "✗ "+result.Path+" / "+branch.Branch+" was deleted: "+branch.Message)
|
||
case "protected":
|
||
protected++
|
||
caution = append(caution, "◆ "+result.Path+" / "+branch.Branch+": "+branch.Message)
|
||
case "warning":
|
||
warnings++
|
||
caution = append(caution, "! "+result.Path+" / "+branch.Branch+": "+branch.Message)
|
||
case "untracked":
|
||
untracked++
|
||
caution = append(caution, "◦ "+result.Path+" / "+branch.Branch+": "+branch.Message)
|
||
}
|
||
}
|
||
}
|
||
details := append(critical, caution...)
|
||
|
||
header := titleStyle.Render("LAST RUN") + fmt.Sprintf(" %s %s %s %s",
|
||
readyStyle.Render(fmt.Sprintf("%d changed", changed)),
|
||
mutedStyle.Render(fmt.Sprintf("%d unchanged", unchanged)),
|
||
blockStyle.Render(fmt.Sprintf("%d failed", failed)),
|
||
mutedStyle.Render(fmt.Sprintf("%d skipped", skipped)))
|
||
if updated+deleted+created > 0 {
|
||
header += titleStyle.Render(fmt.Sprintf(" %d updated · %d deleted · %d created", updated, deleted, created))
|
||
}
|
||
if protected+warnings+untracked > 0 {
|
||
header += titleStyle.Render(fmt.Sprintf(" %d protected · %d warnings · %d untracked", protected, warnings, untracked))
|
||
}
|
||
lines := []string{header}
|
||
shown := min(3, len(details))
|
||
for _, detail := range details[:shown] {
|
||
lines = append(lines, " "+truncateMiddle(detail, max(10, width-2)))
|
||
}
|
||
if len(details) > shown {
|
||
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … %d more; press f to cycle through issues", len(details)-shown)))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func resultHeadline(result syncResult) string {
|
||
if result.Success && len(result.Branches) == 0 {
|
||
return " · up to date"
|
||
}
|
||
if result.Message == "" {
|
||
return ""
|
||
}
|
||
return " · " + firstLine(result.Message)
|
||
}
|
||
|
||
func resultNeedsAttention(result syncResult) bool {
|
||
if !result.Success || result.Skipped {
|
||
return true
|
||
}
|
||
for _, branch := range result.Branches {
|
||
if branch.Action == "warning" || branch.Action == "protected" || branch.Action == "untracked" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func firstAttentionPath(results []syncResult) string {
|
||
for _, result := range results {
|
||
if !result.Success && !result.Skipped {
|
||
return result.Path
|
||
}
|
||
}
|
||
for _, result := range results {
|
||
if result.Skipped {
|
||
return result.Path
|
||
}
|
||
}
|
||
for _, result := range results {
|
||
for _, branch := range result.Branches {
|
||
if branch.Action == "warning" || branch.Action == "protected" || branch.Action == "untracked" {
|
||
return result.Path
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m *tuiModel) focusPath(path string) bool {
|
||
for i, repo := range m.repos {
|
||
if repo.RelativePath == path {
|
||
m.cursor = i
|
||
m.clampViewport()
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (m *tuiModel) focusNextAttention() bool {
|
||
var paths []string
|
||
for _, result := range m.lastResult {
|
||
if resultNeedsAttention(result) {
|
||
paths = append(paths, result.Path)
|
||
}
|
||
}
|
||
if len(paths) == 0 {
|
||
return false
|
||
}
|
||
current := ""
|
||
if len(m.repos) > 0 {
|
||
current = m.repos[m.cursor].RelativePath
|
||
}
|
||
next := paths[0]
|
||
for i, path := range paths {
|
||
if path == current {
|
||
next = paths[(i+1)%len(paths)]
|
||
break
|
||
}
|
||
}
|
||
m.focusPath(next)
|
||
m.status = "Focused issue in " + next
|
||
return true
|
||
}
|
||
|
||
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:]
|
||
}
|