gitFlow/internal/presenter/presenter.go
dimitar 012fbf8ac5 feat: phase 6 — polish: completions, notifications, themes, rules, docs
Close out the plan with the remaining polish items and a full README.

Shell completions (cmd/gitflow):
- New `completion [bash|zsh|fish|powershell]` command backed by cobra's
  generators, wired into the root command

Desktop notifications (internal/notify):
- Send() dispatches to notify-send (Linux) with an osascript fallback
  (macOS); Windows is a documented no-op for now; missing notifiers are
  silent, never errors
- watch --notify sends a notification listing repositories whose state
  changed since the previous frame

Color themes (internal/presenter):
- ThemeMode (dark/light) with ParseTheme; light uses bright ANSI variants
  (90-97) that stay legible on light backgrounds; threaded through the
  table, compact, and suggestions renderers; new --theme flag validated
  and dumped by config

Custom rules (internal/rules):
- Rule{name, field (ahead|behind|stash|changes), op (==,!=,<,<=,>,>=),
  value, label} with upfront validation in both Validate and Eval
- Config gains a `rules:` section (yaml/env only, no flag), validated at
  load; matches render as a "FLAGS (custom rules)" section via a new
  presenter.Flags renderer, shown in scan and watch frames

Docs:
- readme.md fully rewritten: features, install, usage, examples, flag
  table, status classes, configuration reference, AI agent behavior and
  --ai-execute guardrails, development layout, CI, and future work
- implementation.md gains an Implementation Progress section recording
  every phase branch and the deviations from the original plan

Multi-platform:
- Verified cross-compilation for windows/amd64 and darwin/arm64; the
  notify package is split behind build tags

Testing:
- rules: validation, operator semantics, Eval ordering, invalid-rule
  errors
- presenter: ParseTheme, light-theme bright codes (and absence of
  dark-theme codes), Flags rendering (empty = silent, matches render)
- config: rules loading from file, invalid-rule rejection, bad theme and
  bad provider rejection
- notify: no-op behaviour when no notifier is installed (skipped when one
  is, to avoid firing real notifications)

Verified: go build, go vet, go test -race (10 packages), gofmt clean,
windows/darwin cross-compile, completion generation, rules + light theme
smoke test, watch --notify graceful shutdown (exit 0, no orphans).
2026-08-02 08:52:50 +02:00

185 lines
4.2 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)
}