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.
306 lines
7.7 KiB
Go
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] + "…"
|
|
}
|