gitFlow/internal/tui/tui.go
dimitar 3d389ac3e8 feat: M3 — TUI interaction (periodic scan, countdown, change markers)
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.
2026-08-02 09:10:56 +02:00

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] + "…"
}