gitFlow/internal/tui/tui.go
dimitar 2656f29019 feat: M2 — TUI skeleton (bubbletea model/view/styles)
Ship a working `gitflow tui` command: static scan result rendered as a
lipgloss-styled table, with keyboard navigation and a detail pane.

TUI package (internal/tui):
- Model struct: holds scan result, cursor, detail/help toggles, loading
  state, error display, and injected app/AI/config dependencies
- Update: handles WindowSizeMsg (resize), KeyMsg for navigation (j/↑/k/↓,
  Home/End, Enter/Space for detail, ? for help, q/Ctrl-C for quit), and
  async scanResultMsg/scanErrorMsg for the M3 periodic scan loop
- View: repo list table (STATUS/REPOSITORY/BRANCH/AHEAD-BEHIND/CHGS/STASH)
  with cursor highlighting and dimmed error rows, expandable detail pane
  showing files, and a status bar (scan summary + keybinding hints)
- styles.go: lipgloss Styles (Header/Row/CursorRow/StatusBar) with
  dark/light theme palettes that mirror the CLI's ThemeMode
- Loading and empty states; help overlay with keybinding reference

Presenter API surface (needed by the TUI):
- ShortPath exported (home → ~ collapsing, reused by TUI and CLI)
- StatusSymbolFor exported (✓ ✗ ↑ ↓ ⇄ ◉ ▢ ! symbols, reused)
- ShortPath is kept as a wrapper so internal formatter callers are
  unaffected

CLI (cmd/gitflow):
- newTUICmd: resolves config, builds AI provider, calls tui.New() for the
  initial scan, and runs the bubbletea Program
- Wired into root command under `gitflow tui` with full flag set
  (--dir, --interval, --exclude, etc.)

Dependencies: bubbletea v1.3.10, lipgloss v1.1.0, a stable x/term tree

Testing:
- Model logic tests: cursor navigation (arrow + j/k), bottom/top clamping,
  detail toggle (Enter/Space), ? help, q/Ctrl-C quit, view renders
  repo names/branch/status columns, detail pane shows file lists, help
  overlay shows keybindings, loading/empty states

Verified: go build, go vet, go test -race (12 packages), gofmt clean,
`gitflow tui --help` prints command usage.
2026-08-02 09:08:10 +02:00

306 lines
7.7 KiB
Go

// Package tui provides an interactive terminal UI for browsing gitflow scan
// results using bubbletea.
package tui
import (
"context"
"fmt"
"strings"
"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
cursor int
detail bool
help bool
aiPanel bool
width int
height int
loading bool
aiLoading bool
err string
first bool
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
}
// NewEmpty returns a Program that shows an empty state and runs a scan on
// Init (for testing the async flow).
func NewEmpty(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) *tea.Program {
return newProgram(cfg, a, aiProvider, opts, status.ScanResult{}, true)
}
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,
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
}
return nil
}
// 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}
}
// ---- messages --------------------------------------------------------------
type scanTickMsg struct{}
type scanResultMsg struct{ result status.ScanResult }
type scanErrorMsg struct{ err error }
type terminalResizeMsg struct{ width, height int }
// ---- 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 "enter", " ":
m.detail = !m.detail
}
case scanResultMsg:
m.result = msg.result
m.loading = false
m.err = ""
case scanErrorMsg:
m.err = msg.err.Error()
m.loading = false
}
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 {
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
// Column widths
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 {
cursor := " "
if i == m.cursor {
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 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 {
// Dim errored repos; use status color for the status symbol column.
switch s {
case status.StatusError:
return m.styles.Dim(line)
case status.StatusClean:
// keep default
}
return line
}
// ---- detail view -----------------------------------------------------------
func (m Model) detailView(repo status.RepoInfo) string {
var b strings.Builder
w := m.width
if w < 40 {
w = 80 // default when no resize event yet (e.g. tests)
}
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 + "]"
}
right := "? 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 navigate\n" +
" Enter / Space toggle detail pane\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] + "…"
}