Replace the static-scan boot in the TUI with a reactive scan loop anchored on bubbletea.Tick and user-driven 'r' rescan. Scan loop (tea.Tick instead of goroutine — no leak): - Init returns a tick Cmd when --interval > 0; on each tick the model fires scanCmd and chains the next tick in the Update handler, producing a self-regulating interval loop that stops naturally on quit - 'r' key triggers an immediate rescan when the model is not already loading, for on-demand refresh - scanResultMsg handler records prevResult, computes status.Changed into a changed set (keyed by repo path), and updates lastScan - scanErrorMsg stores the error text on the model (displayed in the status bar) without stopping the loop Change detection in the view: - Repos whose state changed since the previous scan get a ▲ prefix (yellow bold) in the repo list, distinguishing them from the > cursor Status bar improvements: - Countdown "next: 23s" when --interval is active, computed from time.Since(lastScan); "scanning…" shown during async scans - Updated keybinding hints: r refresh, g/G home/end Navigation additions: - g/Home jumps to top, G/End jumps to bottom Styles: - Changed style (yellow bold foreground) for ▲ markers - Styles struct now includes all five variants Tests: - Home/End keys navigate to first/last repo - 'r' fires scanCmd when not loading - ▲ marker appears for changed repos - Interval tick fires scan when idle - Countdown "next:" shown in status bar Verified: go build, go vet, go test -race (12 packages), gofmt clean.
373 lines
9.0 KiB
Go
373 lines
9.0 KiB
Go
// Package tui provides an interactive terminal UI for browsing gitflow scan
|
|
// results using bubbletea.
|
|
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
|
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
|
)
|
|
|
|
// Model is the top-level bubbletea model for the TUI.
|
|
type Model struct {
|
|
app *app.App
|
|
ai ai.Provider // nil when AI is disabled
|
|
cfg *config.Config
|
|
opts presenter.Options
|
|
|
|
result status.ScanResult
|
|
prevResult status.ScanResult
|
|
cursor int
|
|
detail bool
|
|
help bool
|
|
aiPanel bool
|
|
|
|
width int
|
|
height int
|
|
|
|
loading bool
|
|
aiLoading bool
|
|
err string
|
|
first bool
|
|
|
|
interval time.Duration
|
|
lastScan time.Time
|
|
changed map[string]bool // repo paths whose state changed since previous scan
|
|
|
|
styles Styles
|
|
}
|
|
|
|
// New builds a bubbletea Program for the TUI. It runs an initial scan
|
|
// synchronously so the first frame shows results immediately.
|
|
func New(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) (*tea.Program, error) {
|
|
result, err := a.ScanOnce(context.Background())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tui: initial scan: %w", err)
|
|
}
|
|
return newProgram(cfg, a, aiProvider, opts, result, false), nil
|
|
}
|
|
|
|
func newProgram(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options, result status.ScanResult, loading bool) *tea.Program {
|
|
m := Model{
|
|
app: a,
|
|
ai: aiProvider,
|
|
cfg: cfg,
|
|
opts: opts,
|
|
result: result,
|
|
cursor: 0,
|
|
detail: false,
|
|
first: true,
|
|
interval: cfg.Interval,
|
|
lastScan: time.Now(),
|
|
changed: map[string]bool{},
|
|
styles: NewStyles(opts),
|
|
}
|
|
if loading {
|
|
m.loading = true
|
|
}
|
|
return tea.NewProgram(m, tea.WithAltScreen())
|
|
}
|
|
|
|
// Init is the initial command.
|
|
func (m Model) Init() tea.Cmd {
|
|
if m.loading {
|
|
return m.scanCmd
|
|
}
|
|
if m.interval > 0 {
|
|
return tea.Tick(m.interval, func(t time.Time) tea.Msg {
|
|
return scanTickMsg{t}
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scanTickMsg is sent by the ticker every interval.
|
|
type scanTickMsg struct{ at time.Time }
|
|
|
|
// scanResultMsg carries a completed scan.
|
|
type scanResultMsg struct{ result status.ScanResult }
|
|
|
|
// scanErrorMsg carries a failed scan.
|
|
type scanErrorMsg struct{ err error }
|
|
|
|
// scanCmd triggers a scan in the background.
|
|
func (m Model) scanCmd() tea.Msg {
|
|
result, err := m.app.ScanOnce(context.Background())
|
|
if err != nil {
|
|
return scanErrorMsg{err}
|
|
}
|
|
return scanResultMsg{result}
|
|
}
|
|
|
|
// ---- update ----------------------------------------------------------------
|
|
|
|
// Update handles incoming messages.
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
case "?":
|
|
m.help = !m.help
|
|
case "down", "j":
|
|
if m.cursor < len(m.result.Repos)-1 {
|
|
m.cursor++
|
|
}
|
|
case "up", "k":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
case "home", "g":
|
|
m.cursor = 0
|
|
case "end", "G":
|
|
if n := len(m.result.Repos); n > 0 {
|
|
m.cursor = n - 1
|
|
}
|
|
case "enter", " ":
|
|
m.detail = !m.detail
|
|
case "r":
|
|
if !m.loading {
|
|
m.loading = true
|
|
return m, m.scanCmd
|
|
}
|
|
}
|
|
|
|
case scanTickMsg:
|
|
if !m.loading {
|
|
m.loading = true
|
|
return m, m.scanCmd
|
|
}
|
|
if m.interval > 0 {
|
|
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
|
|
return scanTickMsg{t}
|
|
})
|
|
}
|
|
|
|
case scanResultMsg:
|
|
if !m.first {
|
|
m.prevResult = m.result
|
|
m.changed = make(map[string]bool, len(msg.result.Repos))
|
|
for _, r := range status.Changed(m.prevResult, msg.result) {
|
|
m.changed[r.Path] = true
|
|
}
|
|
}
|
|
m.result = msg.result
|
|
m.loading = false
|
|
m.err = ""
|
|
m.lastScan = time.Now()
|
|
if m.first {
|
|
m.first = false
|
|
}
|
|
if m.interval > 0 {
|
|
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
|
|
return scanTickMsg{t}
|
|
})
|
|
}
|
|
|
|
case scanErrorMsg:
|
|
m.err = msg.err.Error()
|
|
m.loading = false
|
|
m.lastScan = time.Now()
|
|
if m.interval > 0 {
|
|
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
|
|
return scanTickMsg{t}
|
|
})
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ---- view ------------------------------------------------------------------
|
|
|
|
// View renders the full TUI.
|
|
func (m Model) View() string {
|
|
if m.help {
|
|
return m.helpView()
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(m.repoListView())
|
|
b.WriteString("\n")
|
|
if m.detail && m.cursor < len(m.result.Repos) {
|
|
b.WriteString(m.detailView(m.result.Repos[m.cursor]))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(m.statusBarView())
|
|
return b.String()
|
|
}
|
|
|
|
// ---- repo list -------------------------------------------------------------
|
|
|
|
func (m Model) repoListView() string {
|
|
if m.loading && m.first {
|
|
return fmt.Sprintf("\n %s scanning %s...\n", m.styles.Dim("⏳"), m.cfg.Dir)
|
|
}
|
|
if len(m.result.Repos) == 0 {
|
|
return fmt.Sprintf("\n %s\n", m.styles.Dim("no repositories found under "+m.cfg.Dir))
|
|
}
|
|
|
|
var b strings.Builder
|
|
header := m.styles.Header
|
|
fmt.Fprintf(&b, "%s%-5s %-30s %-15s %-10s %5s %6s\n%s",
|
|
header.Render(""),
|
|
header.Render("STATUS"),
|
|
header.Render("REPOSITORY"),
|
|
header.Render("BRANCH"),
|
|
header.Render("AHEAD/BEHIND"),
|
|
header.Render("CHGS"),
|
|
header.Render("STASH"),
|
|
header.Render(""),
|
|
)
|
|
|
|
for i, repo := range m.result.Repos {
|
|
changed := m.changed[repo.Path]
|
|
cursor := " "
|
|
if i == m.cursor {
|
|
cursor = ">"
|
|
} else if changed {
|
|
cursor = "▲"
|
|
}
|
|
|
|
ab := "-"
|
|
if repo.AheadBy > 0 || repo.BehindBy > 0 {
|
|
ab = fmt.Sprintf("%d/%d", repo.AheadBy, repo.BehindBy)
|
|
}
|
|
|
|
chs := "-"
|
|
if n := repo.FileCount(); n > 0 {
|
|
chs = fmt.Sprintf("%d", n)
|
|
}
|
|
|
|
stash := "-"
|
|
if repo.StashCount > 0 {
|
|
stash = fmt.Sprintf("%d", repo.StashCount)
|
|
}
|
|
|
|
line := fmt.Sprintf("%s %-5s %-30s %-15s %5s %6s %6s\n",
|
|
cursor,
|
|
presenter.StatusSymbolFor(repo.Status),
|
|
truncate(shortPath(repo.Path), 29),
|
|
truncate(repo.Branch, 14),
|
|
ab,
|
|
chs,
|
|
stash,
|
|
)
|
|
line = m.colorLine(repo.Status, cursor, line)
|
|
if changed && cursor != ">" {
|
|
line = m.styles.Changed.Render(line)
|
|
}
|
|
|
|
if i == m.cursor {
|
|
b.WriteString(m.styles.CursorRow.Render(line))
|
|
} else {
|
|
b.WriteString(m.styles.Row.Render(line))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m Model) colorLine(s status.RepoStatus, cursor string, line string) string {
|
|
switch s {
|
|
case status.StatusError:
|
|
return m.styles.Dim(line)
|
|
}
|
|
return line
|
|
}
|
|
|
|
// ---- detail view -----------------------------------------------------------
|
|
|
|
func (m Model) detailView(repo status.RepoInfo) string {
|
|
var b strings.Builder
|
|
w := m.width
|
|
if w < 40 {
|
|
w = 80
|
|
}
|
|
sep := m.styles.Dim(strings.Repeat("─", max(0, w-2)))
|
|
b.WriteString(sep + "\n")
|
|
line := fmt.Sprintf(" %s branch: %s | remote: %s | ahead: %d behind: %d",
|
|
repo.Path, repo.Branch, truncate(repo.RemoteURL, 30), repo.AheadBy, repo.BehindBy)
|
|
b.WriteString(m.styles.Dim(line) + "\n")
|
|
d := fmt.Sprintf(" staged: %d modified: %d untracked: %d stash: %d",
|
|
len(repo.StagedFiles), len(repo.ModifiedFiles), len(repo.UntrackedFiles), repo.StashCount)
|
|
b.WriteString(d + "\n")
|
|
if repo.Error != "" {
|
|
b.WriteString(m.styles.Err(repo.Error) + "\n")
|
|
}
|
|
if len(repo.StagedFiles) > 0 {
|
|
b.WriteString(" staged: " + strings.Join(repo.StagedFiles, ", ") + "\n")
|
|
}
|
|
if len(repo.ModifiedFiles) > 0 {
|
|
b.WriteString(" modified: " + strings.Join(repo.ModifiedFiles, ", ") + "\n")
|
|
}
|
|
if len(repo.UntrackedFiles) > 0 {
|
|
b.WriteString(" untracked: " + strings.Join(repo.UntrackedFiles, ", ") + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ---- status bar ------------------------------------------------------------
|
|
|
|
func (m Model) statusBarView() string {
|
|
s := m.result.Summary()
|
|
left := fmt.Sprintf("%d repos | %d clean | %d need attention | %d errors",
|
|
s.Total, s.Clean, s.Attention, s.Errored)
|
|
if m.err != "" {
|
|
left += " [error: " + m.err + "]"
|
|
}
|
|
if m.interval > 0 {
|
|
elapsed := time.Since(m.lastScan).Round(time.Second)
|
|
next := m.interval - elapsed
|
|
if next < 0 {
|
|
next = 0
|
|
}
|
|
left += fmt.Sprintf(" | next: %v", next)
|
|
}
|
|
if m.loading {
|
|
left += " | scanning…"
|
|
}
|
|
right := "r refresh ? help q quit ↑↓ nav Enter detail"
|
|
bar := left + strings.Repeat(" ", max(0, m.width-len(left)-len(right))) + right
|
|
return m.styles.StatusBar.Render(bar)
|
|
}
|
|
|
|
// ---- help ------------------------------------------------------------------
|
|
|
|
func (m Model) helpView() string {
|
|
return m.styles.Dim(
|
|
"\n KEYBINDINGS\n" +
|
|
" ↑/↓ or j/k g/G navigate\n" +
|
|
" Enter / Space toggle detail pane\n" +
|
|
" r refresh now\n" +
|
|
" a AI suggestions (requires --ai)\n" +
|
|
" ? toggle this help\n" +
|
|
" q / Ctrl-C quit\n",
|
|
)
|
|
}
|
|
|
|
// ---- helpers ---------------------------------------------------------------
|
|
|
|
func shortPath(p string) string {
|
|
return presenter.ShortPath(p)
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n-1] + "…"
|
|
}
|