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.
190 lines
4.3 KiB
Go
190 lines
4.3 KiB
Go
// Package presenter renders scan results as human- or machine-readable
|
|
// output: a colorized terminal table, indented JSON, or a compact
|
|
// one-liner format.
|
|
package presenter
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
|
)
|
|
|
|
// ColorMode controls ANSI color rendering.
|
|
type ColorMode int
|
|
|
|
const (
|
|
ColorAuto ColorMode = iota // color only when writing to a terminal
|
|
ColorAlways // always emit ANSI color
|
|
ColorNever // never emit ANSI color
|
|
)
|
|
|
|
// ParseColorMode converts a CLI string to a ColorMode; anything unknown
|
|
// falls back to auto.
|
|
func ParseColorMode(s string) ColorMode {
|
|
switch s {
|
|
case "always":
|
|
return ColorAlways
|
|
case "never":
|
|
return ColorNever
|
|
default:
|
|
return ColorAuto
|
|
}
|
|
}
|
|
|
|
// ThemeMode selects the ANSI palette used for colored output.
|
|
type ThemeMode int
|
|
|
|
const (
|
|
ThemeDark ThemeMode = iota // classic dark-terminal colors
|
|
ThemeLight // brighter hues for light backgrounds
|
|
)
|
|
|
|
// ParseTheme converts a CLI string to a ThemeMode; anything unknown falls
|
|
// back to dark.
|
|
func ParseTheme(s string) ThemeMode {
|
|
if s == "light" {
|
|
return ThemeLight
|
|
}
|
|
return ThemeDark
|
|
}
|
|
|
|
// Options control rendering behaviour.
|
|
type Options struct {
|
|
Color ColorMode
|
|
Theme ThemeMode
|
|
}
|
|
|
|
// Formatter renders a ScanResult to a writer.
|
|
type Formatter interface {
|
|
Format(w io.Writer, result status.ScanResult) error
|
|
}
|
|
|
|
// For returns the formatter matching a format name ("table", "json", or
|
|
// "compact").
|
|
func For(format string, opts Options) (Formatter, error) {
|
|
switch format {
|
|
case "table":
|
|
return &TableFormatter{opts: opts}, nil
|
|
case "json":
|
|
return &JSONFormatter{}, nil
|
|
case "compact":
|
|
return &CompactFormatter{opts: opts}, nil
|
|
default:
|
|
return nil, fmt.Errorf("presenter: unsupported format %q (want table, json, or compact)", format)
|
|
}
|
|
}
|
|
|
|
// Present renders result to w in the given format.
|
|
func Present(w io.Writer, format string, result status.ScanResult, opts Options) error {
|
|
formatter, err := For(format, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return formatter.Format(w, result)
|
|
}
|
|
|
|
// renderer resolves ANSI escape codes according to the color mode, the
|
|
// destination, and the NO_COLOR convention (https://no-color.org).
|
|
type renderer struct {
|
|
green string
|
|
yellow string
|
|
red string
|
|
cyan string
|
|
blue string
|
|
reset string
|
|
}
|
|
|
|
// newRendererTheme resolves ANSI escape codes according to the color mode,
|
|
// the theme, the destination, and the NO_COLOR convention
|
|
// (https://no-color.org).
|
|
func newRendererTheme(mode ColorMode, theme ThemeMode, w io.Writer) renderer {
|
|
useColor := false
|
|
switch mode {
|
|
case ColorAlways:
|
|
useColor = true
|
|
case ColorNever:
|
|
useColor = false
|
|
default: // ColorAuto
|
|
useColor = isTTY(w) && os.Getenv("NO_COLOR") == ""
|
|
}
|
|
if !useColor {
|
|
return renderer{}
|
|
}
|
|
if theme == ThemeLight {
|
|
// Bright variants (90-97) stay legible on light backgrounds.
|
|
return renderer{
|
|
green: "\x1b[92m",
|
|
yellow: "\x1b[93m",
|
|
red: "\x1b[91m",
|
|
cyan: "\x1b[96m",
|
|
blue: "\x1b[94m",
|
|
reset: "\x1b[0m",
|
|
}
|
|
}
|
|
return renderer{
|
|
green: "\x1b[32m",
|
|
yellow: "\x1b[33m",
|
|
red: "\x1b[31m",
|
|
cyan: "\x1b[36m",
|
|
blue: "\x1b[34m",
|
|
reset: "\x1b[0m",
|
|
}
|
|
}
|
|
|
|
// paint wraps text in the color for the given status.
|
|
func (r renderer) paint(s status.RepoStatus, text string) string {
|
|
var code string
|
|
switch s {
|
|
case status.StatusClean:
|
|
code = r.green
|
|
case status.StatusModified:
|
|
code = r.yellow
|
|
case status.StatusDiverged, status.StatusError:
|
|
code = r.red
|
|
case status.StatusAhead:
|
|
code = r.blue
|
|
case status.StatusBehind:
|
|
code = r.yellow
|
|
case status.StatusDetached, status.StatusBare:
|
|
code = r.cyan
|
|
}
|
|
if code == "" {
|
|
return text
|
|
}
|
|
return code + text + r.reset
|
|
}
|
|
|
|
func isTTY(w io.Writer) bool {
|
|
f, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Mode()&os.ModeCharDevice != 0
|
|
}
|
|
|
|
// ShortPath renders a path with the home directory collapsed to "~".
|
|
func ShortPath(p string) string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return p
|
|
}
|
|
rel, err := filepath.Rel(home, p)
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
|
return p
|
|
}
|
|
return filepath.Join("~", rel)
|
|
}
|
|
|
|
// shortPath is the internal name used by formatters; kept for compatibility.
|
|
func shortPath(p string) string {
|
|
return ShortPath(p)
|
|
}
|