diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index a0276f0..73a7b5c 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -3,12 +3,9 @@ package main import ( "fmt" "os" - "strconv" "strings" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/github" @@ -111,7 +108,7 @@ func main() { Render: func(de ui.DisplayEvent, w, h int, theme blit.Theme) string { return renderEventDetail(de, w, theme) }, - OnKey: func(de ui.DisplayEvent, key tea.KeyMsg) tea.Cmd { + OnKey: func(de ui.DisplayEvent, key blit.KeyMsg) blit.Cmd { if key.String() == "o" { if url := de.Event.URL(); url != "" { blit.OpenURL(url) @@ -126,76 +123,8 @@ func main() { }) stream.DetailOverlay = detailOverlay - // Config editor using blit.ConfigEditor - configEditor := blit.NewConfigEditor([]blit.ConfigField{ - { - Label: "Interval (sec)", - Group: "Polling", - Hint: "How often to poll GitHub for new events (min 5)", - Get: func() string { return strconv.Itoa(cfg.Interval) }, - Validate: func(v string) error { - n, err := strconv.Atoi(v) - if err != nil || n < 5 { - return fmt.Errorf("must be a number >= 5") - } - return nil - }, - Set: func(v string) error { - n, _ := strconv.Atoi(v) - cfg.Interval = n - return config.Save(cfg) - }, - }, - { - Label: "Add repo", - Group: "Repos", - Hint: "Add a new repo to watch (owner/repo format)", - Get: func() string { return "" }, - Validate: func(v string) error { - v = strings.TrimSpace(v) - if v == "" || !strings.Contains(v, "/") { - return fmt.Errorf("must be owner/repo format") - } - for _, r := range cfg.RepoEntries { - if r.Name == v { - return fmt.Errorf("repo already exists") - } - } - return nil - }, - Set: func(v string) error { - v = strings.TrimSpace(v) - cfg.RepoEntries = append(cfg.RepoEntries, config.RepoEntry{Name: v}) - return config.Save(cfg) - }, - }, - { - Label: "Remove repo", - Group: "Repos", - Hint: "Remove a watched repo (owner/repo format)", - Get: func() string { return "" }, - Validate: func(v string) error { - v = strings.TrimSpace(v) - for _, r := range cfg.RepoEntries { - if r.Name == v { - return nil - } - } - return fmt.Errorf("repo not found") - }, - Set: func(v string) error { - v = strings.TrimSpace(v) - filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries)) - for _, r := range cfg.RepoEntries { - if r.Name != v { - filtered = append(filtered, r) - } - } - cfg.RepoEntries = filtered - return config.Save(cfg) - }, - }, - }) + // Config editor auto-generated from blit struct tags + configEditor := config.Editor() // Signal-driven status bar. Set() is called via goroutine to avoid // deadlocking — bubbletea's p.msgs is unbuffered, and Signal.Set triggers @@ -226,7 +155,7 @@ func main() { cmdBar := blit.NewCommandBar([]blit.Command{ { Name: "add", Args: true, Hint: "Add a repo (owner/repo)", - Run: func(args string) tea.Cmd { + Run: func(args string) blit.Cmd { args = strings.TrimSpace(args) if args == "" || !strings.Contains(args, "/") { return nil @@ -238,7 +167,7 @@ func main() { }, { Name: "remove", Aliases: []string{"rm"}, Args: true, Hint: "Remove a repo", - Run: func(args string) tea.Cmd { + Run: func(args string) blit.Cmd { args = strings.TrimSpace(args) filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries)) for _, r := range cfg.RepoEntries { @@ -253,7 +182,7 @@ func main() { }, { Name: "sort", Args: true, Hint: "Sort newest|oldest", - Run: func(args string) tea.Cmd { + Run: func(args string) blit.Cmd { args = strings.TrimSpace(args) if args == "newest" && !stream.IsNewestFirst() { stream.ToggleSort() @@ -267,7 +196,7 @@ func main() { }, { Name: "filter", Args: true, Hint: "filter repo: or type:", - Run: func(args string) tea.Cmd { + Run: func(args string) blit.Cmd { args = strings.TrimSpace(args) if strings.HasPrefix(args, "repo:") { stream.SetRepoFilter(strings.TrimPrefix(args, "repo:")) @@ -281,7 +210,7 @@ func main() { }, { Name: "clear", Hint: "Clear all filters", - Run: func(_ string) tea.Cmd { + Run: func(_ string) blit.Cmd { stream.ClearFilters() updateStatusRight() return nil @@ -289,7 +218,7 @@ func main() { }, { Name: "theme", Args: true, Hint: "Set theme by name", - Run: func(args string) tea.Cmd { + Run: func(args string) blit.Cmd { args = strings.TrimSpace(args) if t, ok := presets[args]; ok { cfg.Theme = args @@ -301,7 +230,7 @@ func main() { }, { Name: "quit", Aliases: []string{"q"}, Hint: "Quit", - Run: func(_ string) tea.Cmd { return tea.Quit }, + Run: func(_ string) blit.Cmd { return blit.Quit }, }, }) @@ -427,10 +356,15 @@ func main() { blit.WithAnimations(true), ) - if err := app.Run(); err != nil { + action, err := app.Run() + if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + if action == blit.UpdateActionRestart { + fmt.Fprintln(os.Stderr, "Update installed. Please restart gitstream.") + os.Exit(0) + } } func printHelp() { @@ -472,10 +406,10 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { bc.SetSize(w, 1) bc.SetTheme(theme) - labelStyle := lipgloss.NewStyle().Foreground(theme.Muted) - valStyle := lipgloss.NewStyle().Foreground(theme.Text) + labelStyle := blit.NewStyle().Foreground(theme.Muted) + valStyle := blit.NewStyle().Foreground(theme.Text) color := ui.EventColor(ev.Type, theme) - typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) + typeStyle := blit.NewStyle().Foreground(color).Bold(true) lines := []string{ bc.View(), @@ -512,7 +446,7 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { if len(ev.Payload.Commits) > 0 { lines = append(lines, "") lines = append(lines, labelStyle.Render("Commits:")) - shaStyle := lipgloss.NewStyle().Foreground(theme.Warn) + shaStyle := blit.NewStyle().Foreground(theme.Warn) for _, c := range ev.Payload.Commits { sha := c.SHA if len(sha) > 7 { @@ -568,8 +502,8 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { if cd := ev.CompareData; cd != nil { lines = append(lines, "") lines = append(lines, labelStyle.Render(fmt.Sprintf("Files changed: %d, Commits: %d", len(cd.Files), cd.TotalCommits))) - addStyle := lipgloss.NewStyle().Foreground(theme.Positive) - delStyle := lipgloss.NewStyle().Foreground(theme.Negative) + addStyle := blit.NewStyle().Foreground(theme.Positive) + delStyle := blit.NewStyle().Foreground(theme.Negative) for _, f := range cd.Files { adds := addStyle.Render(fmt.Sprintf("+%d", f.Additions)) dels := delStyle.Render(fmt.Sprintf("-%d", f.Deletions)) @@ -588,7 +522,7 @@ func resolveTheme(name string) blit.Theme { } } t := blit.DefaultTheme() - t.Extra = map[string]lipgloss.Color{ + t.Extra = map[string]blit.Color{ "info": "#06b6d4", "create": "#22c55e", "delete": "#ef4444", diff --git a/go.mod b/go.mod index d7d336b..8274ece 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,8 @@ module github.com/moneycaringcoder/gitstream-tui go 1.26.1 require ( - github.com/blitui/blit v0.1.2 + github.com/blitui/blit v0.2.24 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/ansi v0.11.6 ) @@ -19,6 +18,7 @@ require ( github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/glamour v1.0.0 // indirect github.com/charmbracelet/keygen v0.5.3 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/log v0.4.1 // indirect github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 // indirect github.com/charmbracelet/wish v1.4.7 // indirect diff --git a/go.sum b/go.sum index 065e68b..60724eb 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/blitui/blit v0.1.2 h1:2TZ836XZ9i8EOA9S8jrTtXvA9O7PqHwCih65fxA5LRY= -github.com/blitui/blit v0.1.2/go.mod h1:OQ3XhjGhDneebNJs/ldXqRRXKG1H3+XrYWefdHDD+LY= +github.com/blitui/blit v0.2.24 h1:42A20iL8O8ADIPrIgZDlSFv9gUAC79FARfpfVPBdm+w= +github.com/blitui/blit v0.2.24/go.mod h1:OQ3XhjGhDneebNJs/ldXqRRXKG1H3+XrYWefdHDD+LY= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= diff --git a/internal/config/config.go b/internal/config/config.go index 3e94169..3268394 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,13 +20,11 @@ type RepoEntry struct { // - name: owner/repo // path: /home/user/projects/repo func (r *RepoEntry) UnmarshalYAML(unmarshal func(interface{}) error) error { - // Try plain string first var s string if err := unmarshal(&s); err == nil { r.Name = s return nil } - // Otherwise struct type raw RepoEntry return unmarshal((*raw)(r)) } @@ -40,10 +38,12 @@ func (r RepoEntry) MarshalYAML() (interface{}, error) { return (raw)(r), nil } +// Config holds the application configuration. +// blit struct tags enable auto-generated ConfigEditor and CLI commands. type Config struct { - RepoEntries []RepoEntry `yaml:"repos"` - Interval int `yaml:"interval"` - Theme string `yaml:"theme,omitempty"` + RepoEntries []RepoEntry `yaml:"repos" blit:"label=Repos,group=Config,hint=Watched repos (owner/repo format)"` + Interval int `yaml:"interval" blit:"label=Interval (sec),group=Polling,hint=Poll frequency (min 5),default=30,min=5"` + Theme string `yaml:"theme,omitempty" blit:"label=Theme,group=Appearance,hint=Theme name (use ctrl+t to pick),readonly=true"` } // Repos returns just the repo name strings for backward compatibility. @@ -66,40 +66,56 @@ func (c *Config) ExplicitPaths() map[string]string { return m } -func DefaultPath() string { - p, err := blit.DefaultConfigPath("gitstream") - if err != nil { - home, _ := os.UserHomeDir() - return home + "/.config/gitstream/config.yaml" - } - return p -} +// blitCfg is the blit.Config wrapper. Initialized on first Load. +var blitCfg *blit.Config[Config] +// Load loads the config using blit.Config[T] with struct tag defaults. func Load() (*Config, error) { - path := DefaultPath() - - var cfg Config - if err := blit.LoadYAML(path, &cfg); err != nil { - return nil, err - } - - // LoadYAML returns nil for missing files, so check if we got anything. - if cfg.RepoEntries == nil { - // Check if the file actually exists to give a helpful message. - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil, fmt.Errorf("no config found at %s - run 'gitstream add owner/repo' to get started", path) + var err error + if blitCfg == nil { + blitCfg, err = blit.LoadConfig[Config]("gitstream") + if err != nil { + // Check if the file doesn't exist to give a helpful message. + path, pathErr := blit.DefaultConfigPath("gitstream") + if pathErr == nil { + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + return nil, fmt.Errorf("no config found at %s - run 'gitstream add owner/repo' to get started", path) + } + } + return nil, err } } + return &blitCfg.Value, nil +} - if cfg.Interval <= 0 { - cfg.Interval = 30 +// Save persists the current config value. +func Save(cfg *Config) error { + if blitCfg != nil { + blitCfg.Value = *cfg + return blitCfg.Save() } + path, _ := blit.DefaultConfigPath("gitstream") + return blit.SaveYAML(path, cfg) +} - return &cfg, nil +// Editor returns a blit.ConfigEditor auto-generated from struct tags. +func Editor() *blit.ConfigEditor { + if blitCfg == nil { + if _, err := Load(); err != nil { + return nil + } + } + return blitCfg.Editor() } -func Save(cfg *Config) error { - return blit.SaveYAML(DefaultPath(), cfg) +// CLICommands returns auto-generated CLI commands for config fields. +func CLICommands() map[string]blit.CLICommand { + if blitCfg == nil { + if _, err := Load(); err != nil { + return nil + } + } + return blitCfg.CLICommands() } func AddRepo(repo string) error { @@ -109,6 +125,10 @@ func AddRepo(repo string) error { cfg, err := Load() if err != nil { cfg = &Config{Interval: 30} + blitCfg, _ = blit.LoadConfig[Config]("gitstream") + if blitCfg != nil { + blitCfg.Value = *cfg + } } for _, r := range cfg.RepoEntries { diff --git a/internal/github/events.go b/internal/github/events.go index 3dde8ff..abe7bcc 100644 --- a/internal/github/events.go +++ b/internal/github/events.go @@ -4,9 +4,7 @@ import ( "encoding/json" "fmt" "os/exec" - "strconv" "strings" - "sync" "time" ) @@ -81,7 +79,7 @@ type CompareResult struct { } `json:"files"` } -// FetchCompare gets diff stats between two commits. +// FetchCompare gets diff stats between two commits via gh api. func FetchCompare(repo, base, head string) (*CompareResult, error) { cmd := exec.Command("gh", "api", fmt.Sprintf("repos/%s/compare/%s...%s", repo, base, head), "--jq", `{total_commits: .total_commits, files: [.files[] | {filename, additions, deletions, changes}]}`) @@ -96,140 +94,11 @@ func FetchCompare(repo, base, head string) (*CompareResult, error) { return &result, nil } -// etagCache stores ETags per URL for conditional requests. -var ( - etagMu sync.Mutex - etagStore = make(map[string]string) // url -> etag -) - -// FetchResult holds the outcome of an ETag-aware fetch. -type FetchResult struct { - Events []Event - NotModified bool // true if 304 — data unchanged, didn't cost a rate limit point - RateRemain int // parsed from X-RateLimit-Remaining header - RateLimit int // parsed from X-RateLimit-Limit header -} - -// FetchEvents fetches recent events for a repo using the gh CLI with ETag support. -// page is 1-indexed; each page returns up to 30 events from the API. -// When the server returns 304 Not Modified, NotModified is true and Events is nil. -func FetchEvents(repo string, limit int, page int) (*FetchResult, error) { - if page < 1 { - page = 1 - } - url := fmt.Sprintf("repos/%s/events?per_page=30&page=%d", repo, page) - - args := []string{"api", url, "--include", "--cache", "0s"} - - // Add ETag header if we have one cached - etagMu.Lock() - etag := etagStore[url] - etagMu.Unlock() - if etag != "" { - args = append(args, "-H", fmt.Sprintf("If-None-Match: %s", etag)) - } - - cmd := exec.Command("gh", args...) - out, err := cmd.CombinedOutput() - if err != nil { - outStr := string(out) - // gh exits non-zero on 304 — check if it's a Not Modified response - if strings.Contains(outStr, "304 Not Modified") || strings.Contains(outStr, "HTTP/2.0 304") { - rl := parseRateLimitHeaders(outStr) - return &FetchResult{NotModified: true, RateRemain: rl.Remaining, RateLimit: rl.Limit}, nil - } - return nil, fmt.Errorf("gh api failed for %s (page %d): %w", repo, page, err) - } - - // Parse headers and body from --include output - outStr := string(out) - headerEnd, body := splitHeaderBody(outStr) - - // Extract and cache the ETag - if newEtag := parseHeader(headerEnd, "ETag"); newEtag != "" { - etagMu.Lock() - etagStore[url] = newEtag - etagMu.Unlock() - } - - // Parse rate limit from headers - rl := parseRateLimitHeaders(headerEnd) - - var events []Event - if err := json.Unmarshal([]byte(body), &events); err != nil { - return nil, fmt.Errorf("json parse failed for %s: %w", repo, err) - } - - if len(events) > limit { - events = events[:limit] - } - - return &FetchResult{Events: events, RateRemain: rl.Remaining, RateLimit: rl.Limit}, nil -} - -// splitHeaderBody splits `gh api --include` output into headers and JSON body. -func splitHeaderBody(raw string) (headers string, body string) { - // gh --include outputs: HTTP status line, headers, blank line, then JSON body - // Find the first '{' or '[' that starts the JSON body - for i, ch := range raw { - if ch == '[' || ch == '{' { - return raw[:i], raw[i:] - } - } - return raw, "" -} - -// parseHeader extracts a header value from raw header text. -func parseHeader(headers, name string) string { - lower := strings.ToLower(name) - for _, line := range strings.Split(headers, "\n") { - if idx := strings.Index(line, ":"); idx > 0 { - key := strings.TrimSpace(line[:idx]) - if strings.ToLower(key) == lower { - return strings.TrimSpace(line[idx+1:]) - } - } - } - return "" -} - -// RateLimit holds GitHub API rate limit info. -type RateLimit struct { - Remaining int - Limit int -} - -func parseRateLimitHeaders(headers string) RateLimit { - var rl RateLimit - if v := parseHeader(headers, "X-RateLimit-Remaining"); v != "" { - rl.Remaining, _ = strconv.Atoi(v) - } - if v := parseHeader(headers, "X-RateLimit-Limit"); v != "" { - rl.Limit, _ = strconv.Atoi(v) - } - return rl -} - -// FetchRateLimit queries the GitHub API rate limit. -func FetchRateLimit() (*RateLimit, error) { - cmd := exec.Command("gh", "api", "rate_limit", "--jq", ".rate | {remaining, limit}") - out, err := cmd.Output() - if err != nil { - return nil, err - } - var rl RateLimit - if err := json.Unmarshal(out, &rl); err != nil { - return nil, err - } - return &rl, nil -} - // EnrichPushEvent fetches compare stats and populates the event's detail cache. func EnrichPushEvent(ev *Event) { if ev.Type != "PushEvent" || ev.Payload.Before == "" || ev.Payload.Head == "" { return } - // Skip if before is all zeros (new branch) if ev.Payload.Before == "0000000000000000000000000000000000000000" { return } @@ -240,7 +109,6 @@ func EnrichPushEvent(ev *Event) { ev.CompareData = result } - // Label returns a short human-readable label for an event type. func (e *Event) Label() string { switch e.Type { diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 3eb79a3..c9a0f02 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -1,13 +1,10 @@ package ui import ( - "fmt" "sort" "strings" - "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" blit "github.com/blitui/blit" "github.com/blitui/blit/charts" @@ -89,14 +86,14 @@ func (d *DebugOverlay) View() string { content = strings.Join(cLines, "\n") // Render the bordered box - title := lipgloss.NewStyle(). + title := blit.NewStyle(). Bold(true). Foreground(th.Accent). Render(" Debug Console ") - box := lipgloss.NewStyle(). - Width(textW + 2). // +2 for padding(0,1) - Border(lipgloss.RoundedBorder()). + box := blit.NewStyle(). + Width(textW + 2). + Border(blit.RoundedBorder()). BorderForeground(th.Border). Foreground(th.Text). Padding(0, 1) @@ -106,8 +103,8 @@ func (d *DebugOverlay) View() string { // Inject title into the top border lines := strings.Split(rendered, "\n") if len(lines) > 0 { - borderWidth := lipgloss.Width(lines[0]) - titleWidth := lipgloss.Width(title) + borderWidth := blit.Width(lines[0]) + titleWidth := blit.Width(title) if titleWidth+4 < borderWidth { pos := (borderWidth - titleWidth) / 2 runes := []rune(lines[0]) @@ -135,51 +132,13 @@ func (d *DebugOverlay) View() string { // renderStats builds the stats section (API stats, repo health, rate limit, bar chart). func (d *DebugOverlay) renderStats(textW int) string { - var b strings.Builder th := d.theme stats := d.debugLog.GetStats() - statsHeader := lipgloss.NewStyle().Foreground(th.Accent).Bold(true) - dim := lipgloss.NewStyle().Foreground(th.Muted) - errStyle := lipgloss.NewStyle().Foreground(th.Negative) - - b.WriteString(statsHeader.Render("API Stats") + "\n") - b.WriteString(dim.Render(fmt.Sprintf(" Total calls: %d", stats.TotalCalls)) + "\n") - b.WriteString(dim.Render(fmt.Sprintf(" Successful: %d", stats.SuccessCalls)) + "\n") - if stats.FailedCalls > 0 { - b.WriteString(errStyle.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") - } else { - b.WriteString(dim.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") - } - b.WriteString(dim.Render(fmt.Sprintf(" Total events: %d", stats.TotalEvents)) + "\n") - if !stats.LastFetchAt.IsZero() { - ago := time.Since(stats.LastFetchAt).Truncate(time.Second) - b.WriteString(dim.Render(fmt.Sprintf(" Last fetch: %s ago", ago)) + "\n") - } - - // Sorted repo keys for stable render order - repoKeys := make([]string, 0, len(stats.RepoHealth)) - for repo := range stats.RepoHealth { - repoKeys = append(repoKeys, repo) - } - sort.Strings(repoKeys) - - if len(repoKeys) > 0 { - b.WriteString("\n") - b.WriteString(statsHeader.Render("Repo Health") + "\n") - for _, repo := range repoKeys { - h := stats.RepoHealth[repo] - var badge string - if h.LastSuccess { - badge = blit.Badge("OK", th.Positive, true) - } else { - badge = blit.Badge("FAIL", th.Negative, true) - } - b.WriteString(fmt.Sprintf(" %s %s", badge, dim.Render(repo)) + "\n") - } - } + // Use StatsCollector.View() for the standard stats rendering + statsView := d.debugLog.Stats().View(textW, 20, th) - // Cap chart width: at most 60 cols or half the text area, whichever is larger + // Append per-repo bar chart chartW := textW / 2 if chartW < 30 { chartW = 30 @@ -191,29 +150,18 @@ func (d *DebugOverlay) renderStats(textW int) string { chartW = textW - 2 } - // Rate limit gauge - if stats.RateLimit > 0 { - b.WriteString("\n") - b.WriteString(statsHeader.Render("Rate Limit") + "\n") - gauge := charts.NewGauge( - float64(stats.RateRemain), - float64(stats.RateLimit), - []float64{float64(stats.RateLimit) * 0.2, float64(stats.RateLimit) * 0.5}, - fmt.Sprintf("%d/%d", stats.RateRemain, stats.RateLimit), - ) - gauge.SetTheme(th) - gauge.SetSize(chartW, 1) - b.WriteString(" " + gauge.View() + "\n") + repoKeys := make([]string, 0, len(stats.Sources)) + for repo := range stats.Sources { + repoKeys = append(repoKeys, repo) } + sort.Strings(repoKeys) - // Per-repo bar chart if len(repoKeys) > 0 { - b.WriteString("\n") - b.WriteString(statsHeader.Render("Events by Repo") + "\n") + statsHeader := blit.NewStyle().Foreground(th.Accent).Bold(true) var data []float64 var labels []string for _, repo := range repoKeys { - h := stats.RepoHealth[repo] + h := stats.Sources[repo] short := repo if i := strings.LastIndex(repo, "/"); i >= 0 { short = repo[i+1:] @@ -228,10 +176,16 @@ func (d *DebugOverlay) renderStats(textW int) string { bar := charts.NewBar(data, labels, true) bar.SetTheme(th) bar.SetSize(chartW, len(labels)+1) + + var b strings.Builder + b.WriteString(statsView) + b.WriteString("\n") + b.WriteString(statsHeader.Render("Events by Repo") + "\n") b.WriteString(" " + bar.View()) + return b.String() } - return b.String() + return statsView } func (d *DebugOverlay) KeyBindings() []blit.KeyBind { diff --git a/internal/ui/debuglog.go b/internal/ui/debuglog.go index bc481a2..170b288 100644 --- a/internal/ui/debuglog.go +++ b/internal/ui/debuglog.go @@ -8,34 +8,18 @@ import ( blit "github.com/blitui/blit" ) -// DebugLog is a thread-safe log that writes directly to a blit.LogViewer. +// DebugLog composes blit.StatsCollector for API metrics with a blit.LogViewer +// for structured log output. It replaces the former hand-rolled stats tracking. type DebugLog struct { mu sync.Mutex - stats FetchStats + stats *blit.StatsCollector logViewer *blit.LogViewer } -// RepoHealth tracks per-repo fetch health. -type RepoHealth struct { - LastSuccess bool - FailStreak int - UsingCache bool // true when serving cached events due to fetch failure -} - -// FetchStats tracks API call statistics. -type FetchStats struct { - TotalCalls int - SuccessCalls int - FailedCalls int - TotalEvents int - LastFetchAt time.Time - RepoHealth map[string]*RepoHealth - RateRemain int // GitHub API rate limit remaining - RateLimit int // GitHub API rate limit total -} - func NewDebugLog() *DebugLog { - return &DebugLog{} + return &DebugLog{ + stats: blit.NewStatsCollector(), + } } // SetLogViewer wires a blit.LogViewer so that new log entries are appended to it. @@ -45,6 +29,11 @@ func (d *DebugLog) SetLogViewer(lv *blit.LogViewer) { d.logViewer = lv } +// Stats returns the underlying StatsCollector for direct access. +func (d *DebugLog) Stats() *blit.StatsCollector { + return d.stats +} + func (d *DebugLog) Log(level blit.LogLevel, format string, args ...interface{}) { d.mu.Lock() defer d.mu.Unlock() @@ -63,42 +52,25 @@ func (d *DebugLog) Info(format string, args ...interface{}) { d.Log(blit.LogInf func (d *DebugLog) Warn(format string, args ...interface{}) { d.Log(blit.LogWarn, format, args...) } func (d *DebugLog) Error(format string, args ...interface{}) { d.Log(blit.LogError, format, args...) } +// RecordFetch records a fetch result into the StatsCollector. func (d *DebugLog) RecordFetch(repo string, success bool, eventCount int, usingCache bool) { - d.mu.Lock() - defer d.mu.Unlock() - d.stats.TotalCalls++ - d.stats.LastFetchAt = time.Now() - if d.stats.RepoHealth == nil { - d.stats.RepoHealth = make(map[string]*RepoHealth) - } - h, ok := d.stats.RepoHealth[repo] - if !ok { - h = &RepoHealth{} - d.stats.RepoHealth[repo] = h - } if success { - d.stats.SuccessCalls++ - d.stats.TotalEvents += eventCount - h.LastSuccess = true - h.FailStreak = 0 - h.UsingCache = false + d.stats.RecordSuccess(repo, eventCount) } else { - d.stats.FailedCalls++ - h.LastSuccess = false - h.FailStreak++ - h.UsingCache = usingCache + d.stats.RecordFailure(repo, fmt.Errorf("fetch failed")) + if usingCache { + d.stats.RecordCached(repo, eventCount) + } } } +// SetRateLimit updates the rate limit info in the StatsCollector. func (d *DebugLog) SetRateLimit(remaining, limit int) { - d.mu.Lock() - defer d.mu.Unlock() - d.stats.RateRemain = remaining - d.stats.RateLimit = limit + d.stats.SetRateLimit(remaining, limit) } -func (d *DebugLog) GetStats() FetchStats { - d.mu.Lock() - defer d.mu.Unlock() - return d.stats +// GetStats returns a snapshot from the StatsCollector for backward-compatible +// callers that still read field-by-field. +func (d *DebugLog) GetStats() blit.StatsSnapshot { + return d.stats.Snapshot() } diff --git a/internal/ui/panel.go b/internal/ui/panel.go index 89e51bf..a983ccc 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -23,7 +23,8 @@ type StatusPanel struct { focused bool width int theme blit.Theme - styles Styles + styles blit.Styles + panel PanelStyles sections map[string]*blit.CollapsibleSection headerMap map[int]string // line index → repo remote } @@ -34,7 +35,8 @@ func NewStatusPanel() *StatusPanel { sections: make(map[string]*blit.CollapsibleSection), headerMap: make(map[int]string), theme: th, - styles: NewStyles(th), + styles: blit.ThemeStyles(th), + panel: NewPanelStyles(th), } p.listView = blit.NewListView(blit.ListViewOpts[panelLine]{ EmptyText: "No local repos found", @@ -96,7 +98,8 @@ func (p *StatusPanel) SetFocused(f bool) { // SetTheme implements blit.Themed so the App's theme propagates to the ListView. func (p *StatusPanel) SetTheme(t blit.Theme) { p.theme = t - p.styles = NewStyles(t) + p.styles = blit.ThemeStyles(t) + p.panel = NewPanelStyles(t) p.listView.SetTheme(t) } @@ -106,7 +109,7 @@ func (p *StatusPanel) rebuildContent() { if len(p.repoStatus) == 0 { lines = append(lines, panelLine{text: ""}) - lines = append(lines, panelLine{text: p.styles.PanelDim.Render("Scanning for repos...")}) + lines = append(lines, panelLine{text: p.styles.Muted.Render("Scanning for repos...")}) p.listView.SetItems(lines) return } @@ -134,23 +137,23 @@ func (p *StatusPanel) rebuildContent() { indicator = "▶" } p.headerMap[len(lines)] = s.Remote - lines = append(lines, panelLine{text: p.styles.PanelRepo.Render(indicator + " " + short)}) + lines = append(lines, panelLine{text: p.panel.Repo.Render(indicator + " " + short)}) if !sec.Collapsed { if s.Error != nil { - lines = append(lines, panelLine{text: p.styles.PanelDim.Render(" error")}) + lines = append(lines, panelLine{text: p.styles.Muted.Render(" error")}) } else { - lines = append(lines, panelLine{text: p.styles.PanelDim.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) + lines = append(lines, panelLine{text: p.styles.Muted.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) if s.Uncommitted == 0 && s.Unpushed == 0 { - lines = append(lines, panelLine{text: p.styles.PanelClean.Render(" ✓ clean")}) + lines = append(lines, panelLine{text: p.panel.Clean.Render(" ✓ clean")}) } else { if s.Uncommitted > 0 { - lines = append(lines, panelLine{text: p.styles.PanelDirty.Render( + lines = append(lines, panelLine{text: p.panel.Dirty.Render( fmt.Sprintf(" ● %d uncommitted", s.Uncommitted))}) } if s.Unpushed > 0 { - lines = append(lines, panelLine{text: p.styles.PanelWarn.Render( + lines = append(lines, panelLine{text: p.panel.Warn.Render( fmt.Sprintf(" ↑ %d unpushed", s.Unpushed))}) for _, c := range s.UnpushedCommits { msg := c.Message @@ -161,29 +164,29 @@ func (p *StatusPanel) rebuildContent() { if len(msg) > maxLen { msg = msg[:maxLen-1] + "…" } - lines = append(lines, panelLine{text: p.styles.PanelDim.Render( + lines = append(lines, panelLine{text: p.styles.Muted.Render( fmt.Sprintf(" %s %s", c.SHA, msg))}) } } } if !s.HasUpstream { - lines = append(lines, panelLine{text: p.styles.PanelDim.Render(" ⚠ no upstream")}) + lines = append(lines, panelLine{text: p.styles.Muted.Render(" ⚠ no upstream")}) } if s.CI != nil { var ciLine string switch s.CI.Conclusion { case "success": - ciLine = p.styles.PanelClean.Render(" ✓ CI passed") + ciLine = p.panel.Clean.Render(" ✓ CI passed") case "failure": - ciLine = p.styles.PanelCIFail.Render(" ✗ CI failed") + ciLine = p.panel.CIFail.Render(" ✗ CI failed") case "cancelled": - ciLine = p.styles.PanelDim.Render(" ○ CI cancelled") + ciLine = p.styles.Muted.Render(" ○ CI cancelled") default: if s.CI.Status == "in_progress" { - ciLine = p.styles.PanelWarn.Render(" ◌ CI running") + ciLine = p.panel.Warn.Render(" ◌ CI running") } else { - ciLine = p.styles.PanelDim.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) + ciLine = p.styles.Muted.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) } } lines = append(lines, panelLine{text: ciLine}) diff --git a/internal/ui/polling.go b/internal/ui/polling.go index 134429e..ef9af7a 100644 --- a/internal/ui/polling.go +++ b/internal/ui/polling.go @@ -1,11 +1,11 @@ package ui import ( - "fmt" + "os/exec" "sync" - "time" tea "github.com/charmbracelet/bubbletea" + blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/discovery" "github.com/moneycaringcoder/gitstream-tui/internal/github" @@ -26,108 +26,100 @@ type gitStatusMsg struct { statuses []gitstatus.RepoStatus } -// eventCache stores last successful events per repo for fallback. -var ( - eventCacheMu sync.Mutex - eventCache = make(map[string][]github.Event) -) - -// fetchWithRetries fetches events with up to 3 retries and exponential backoff. -func fetchWithRetries(repo string, limit, page int) (*github.FetchResult, error) { - var lastErr error - backoff := 500 * time.Millisecond - for attempt := 0; attempt < 3; attempt++ { - result, err := github.FetchEvents(repo, limit, page) - if err == nil { - return result, nil +// githubToken resolves a GitHub API token via `gh auth token` or +// the GITHUB_TOKEN environment variable. Returns "" if unavailable. +func githubToken() string { + // Try gh CLI first + out, err := exec.Command("gh", "auth", "token").Output() + if err == nil { + if token := trimNewline(string(out)); token != "" { + return token } - lastErr = err - if attempt < 2 { - time.Sleep(backoff) - backoff *= 2 + } + return "" +} + +func trimNewline(s string) string { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == '\n' || s[i] == '\r' { + continue } + return s[:i+1] } - return nil, lastErr + return "" } +// pollEvents uses blit.HTTPResource to fetch GitHub events with ETag caching, +// rate-limit tracking, and response fallback. func pollEvents(cfg *config.Config, debugLog *DebugLog, initial bool) tea.Cmd { return func() tea.Msg { - type result struct { - events []github.Event - errs []string - } - + token := githubToken() repos := cfg.Repos() - var wg sync.WaitGroup - results := make([]result, len(repos)) - pages := 1 if initial { pages = 2 } - var rlMu sync.Mutex - latestRL := github.RateLimit{} + var wg sync.WaitGroup + type result struct { + events []github.Event + errs []string + } + results := make([]result, len(repos)) for idx, repo := range repos { wg.Add(1) go func(i int, r string) { defer wg.Done() - var allEvents []github.Event - var errs []string - fetchFailed := false - notModifiedCount := 0 - - for page := 1; page <= pages; page++ { - fr, err := fetchWithRetries(r, 30, page) - if err != nil { - errs = append(errs, fmt.Sprintf("%s page %d: %v (3 retries exhausted)", r, page, err)) - fetchFailed = true - continue - } - if fr.RateLimit > 0 { - rlMu.Lock() - latestRL = github.RateLimit{Remaining: fr.RateRemain, Limit: fr.RateLimit} - rlMu.Unlock() - } - - if fr.NotModified { - notModifiedCount++ - debugLog.Info("304 Not Modified for %s (page %d) — no rate limit cost", r, page) - continue + hr := blit.NewHTTPResource(blit.HTTPResourceOpts{ + Name: r, + Pages: pages, + BuildURL: func(page int) string { + return blit.GitHubAPIURL("repos/"+r+"/events", 30, page) + }, + Parse: blit.ParseJSONSlice[github.Event](), + ExtraHeaders: func() map[string]string { + if token != "" { + return map[string]string{ + "Authorization": "Bearer " + token, + "Accept": "application/vnd.github+json", + } + } + return map[string]string{ + "Accept": "application/vnd.github+json", + } + }, + CacheResponses: true, + Parallel: true, + OnRateLimit: func(remaining, limit int) { + debugLog.SetRateLimit(remaining, limit) + }, + }) + hr.SetStatsCollector(debugLog.Stats()) + + msg := hr.PollCmd()() + + httpMsg := msg.(blit.HTTPResultMsg) + var allEvents []github.Event + for _, res := range httpMsg.Results { + if slice, ok := res.([]github.Event); ok { + allEvents = append(allEvents, slice...) } - - allEvents = append(allEvents, fr.Events...) - debugLog.Info("Fetched %d events from %s (page %d)", len(fr.Events), r, page) } - if notModifiedCount == pages && !fetchFailed { - eventCacheMu.Lock() - cached := eventCache[r] - eventCacheMu.Unlock() - if len(cached) > 0 { - debugLog.RecordFetch(r, true, len(cached), false) - results[i] = result{events: cached} - return - } + if httpMsg.IsAllNotModified() && len(allEvents) == 0 { + // All pages 304 — stats collector already recorded cached + results[i] = result{} + return } - if len(allEvents) == 0 && fetchFailed { - eventCacheMu.Lock() - cached := eventCache[r] - eventCacheMu.Unlock() - if len(cached) > 0 { - debugLog.Warn("Using cached events for %s (%d events)", r, len(cached)) - debugLog.RecordFetch(r, false, 0, true) - results[i] = result{events: cached, errs: errs} - return - } - debugLog.RecordFetch(r, false, 0, false) - results[i] = result{errs: errs} + if len(httpMsg.Errors) > 0 && len(allEvents) == 0 { + results[i] = result{errs: httpMsg.Errors} return } + // Deduplicate by event ID seen := make(map[string]bool) var deduped []github.Event for _, ev := range allEvents { @@ -136,17 +128,11 @@ func pollEvents(cfg *config.Config, debugLog *DebugLog, initial bool) tea.Cmd { deduped = append(deduped, ev) } } - if len(deduped) > 50 { deduped = deduped[:50] } - eventCacheMu.Lock() - eventCache[r] = deduped - eventCacheMu.Unlock() - - debugLog.RecordFetch(r, true, len(deduped), false) - + // Enrich push events with compare data var ewg sync.WaitGroup for j := range deduped { if deduped[j].Type == "PushEvent" { @@ -159,7 +145,7 @@ func pollEvents(cfg *config.Config, debugLog *DebugLog, initial bool) tea.Cmd { } ewg.Wait() - results[i] = result{events: deduped, errs: errs} + results[i] = result{events: deduped, errs: httpMsg.Errors} }(idx, repo) } wg.Wait() @@ -171,10 +157,6 @@ func pollEvents(cfg *config.Config, debugLog *DebugLog, initial bool) tea.Cmd { allErrors = append(allErrors, r.errs...) } - if latestRL.Limit > 0 { - debugLog.SetRateLimit(latestRL.Remaining, latestRL.Limit) - } - return eventsMsg{events: all, errors: allErrors} } } diff --git a/internal/ui/stream.go b/internal/ui/stream.go index febb728..9ad1695 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -7,7 +7,6 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/discovery" @@ -52,7 +51,8 @@ type EventStream struct { focused bool width int theme blit.Theme - styles Styles + styles blit.Styles + panel PanelStyles epmWindow []float64 // events-per-minute rolling window (last 30 points) lastEPMTick time.Time @@ -72,7 +72,8 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { filteredEvents: make([]DisplayEvent, 0, 256), knownRepos: append([]string{}, cfg.Repos()...), theme: th, - styles: NewStyles(th), + styles: blit.ThemeStyles(th), + panel: NewPanelStyles(th), } columns := []blit.Column{ @@ -91,18 +92,18 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { } switch colIdx { case 0: // Time - return lipgloss.NewStyle().Foreground(theme.Muted).Render(row[colIdx]) + return blit.NewStyle().Foreground(theme.Muted).Render(row[colIdx]) case 2: // Type - colored badge return blit.Badge(row[colIdx], LabelColor(row[colIdx], theme), true) default: return row[colIdx] } }, - RowStyler: func(row blit.Row, idx int, isCursor bool, theme blit.Theme) *lipgloss.Style { + RowStyler: func(row blit.Row, idx int, isCursor bool, theme blit.Theme) *blit.Style { if idx < len(s.filteredEvents) { de := s.filteredEvents[idx] if !de.AddedAt.IsZero() && time.Now().Before(de.AddedAt.Add(flashDuration)) { - st := lipgloss.NewStyle().Background(theme.Flash) + st := blit.NewStyle().Background(theme.Flash) return &st } } @@ -271,13 +272,13 @@ func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { } stats := s.debugLog.GetStats() - if stats.RateLimit > 0 { - ratePct := float64(stats.RateRemain) / float64(stats.RateLimit) * 100 + if stats.RateLimit.Limit > 0 { + ratePct := float64(stats.RateLimit.Remaining) / float64(stats.RateLimit.Limit) * 100 if ratePct < 20 { cmds = append(cmds, blit.ToastCmd( blit.SeverityWarn, "Rate limit low", - fmt.Sprintf("API rate limit at %.0f%% (%d/%d)", ratePct, stats.RateRemain, stats.RateLimit), + fmt.Sprintf("API rate limit at %.0f%% (%d/%d)", ratePct, stats.RateLimit.Remaining, stats.RateLimit.Limit), 0, blit.ToastAction{Label: "Pause", Handler: func() { s.poller.TogglePause() }}, )) @@ -343,16 +344,13 @@ func (s *EventStream) View() string { } func (s *EventStream) renderHeader() string { - title := s.styles.Title.Render("gitstream") - repoList := s.styles.Subtitle.Render(fmt.Sprintf("Watching: %s", strings.Join(s.cfg.Repos(), ", "))) - - // Status line: poll info + health dots + rate limit - var statusParts []string + // Build status line parts + var statusParts []blit.HeaderStatusPart if s.poller.IsPaused() { - statusParts = append(statusParts, lipgloss.NewStyle().Foreground(s.theme.Warn).Render("[PAUSED]")) + statusParts = append(statusParts, blit.StatusPaused(s.theme)) } else if !s.poller.LastPoll().IsZero() { ago := time.Since(s.poller.LastPoll()).Truncate(time.Second) - statusParts = append(statusParts, fmt.Sprintf("Poll %s ago", ago)) + statusParts = append(statusParts, blit.StatusPollAgo(ago.String())) } stats := s.debugLog.GetStats() for _, repo := range s.cfg.Repos() { @@ -360,36 +358,31 @@ func (s *EventStream) renderHeader() string { if i := strings.LastIndex(repo, "/"); i >= 0 { short = repo[i+1:] } - if h, ok := stats.RepoHealth[repo]; ok { - if h.LastSuccess { - statusParts = append(statusParts, blit.Badge("●", s.theme.Positive, false)+" "+short) - } else if h.UsingCache && h.FailStreak < 10 { - statusParts = append(statusParts, blit.Badge("●", s.theme.Warn, false)+" "+short) - } else { - statusParts = append(statusParts, blit.Badge("●", s.theme.Negative, false)+" "+short) - } + if h, ok := stats.Sources[repo]; ok { + statusParts = append(statusParts, blit.HeaderStatusPart{ + Text: blit.HealthDot(short, h.LastSuccess, s.theme), + Color: s.theme.Positive, + }) } else { - statusParts = append(statusParts, blit.Badge("○", s.theme.Muted, false)+" "+short) + statusParts = append(statusParts, blit.HeaderStatusPart{ + Text: blit.HealthDotUnknown(short, s.theme), + }) } } - if stats.RateLimit > 0 { - ratePct := float64(stats.RateRemain) / float64(stats.RateLimit) * 100 - rateColor := s.theme.Positive - if ratePct < 20 { - rateColor = s.theme.Negative - } else if ratePct < 50 { - rateColor = s.theme.Warn - } - statusParts = append(statusParts, lipgloss.NewStyle().Foreground(rateColor).Render( - fmt.Sprintf("API %d/%d", stats.RateRemain, stats.RateLimit))) + if stats.RateLimit.Limit > 0 { + statusParts = append(statusParts, blit.StatusRateLimit(stats.RateLimit.Remaining, stats.RateLimit.Limit, s.theme)) } if len(s.epmWindow) >= 2 { spark, _ := blit.Sparkline(s.epmWindow, 30, nil) - statusParts = append(statusParts, "Activity: "+spark) + statusParts = append(statusParts, blit.HeaderStatusPart{Text: "Activity: " + spark}) } - status := s.styles.Subtitle.Render(strings.Join(statusParts, " ")) - return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status) + h := blit.ComponentHeader{ + Title: "gitstream", + Subtitle: fmt.Sprintf("Watching: %s", strings.Join(s.cfg.Repos(), ", ")), + StatusParts: statusParts, + } + return h.Render(s.width, s.theme) } func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string { @@ -404,15 +397,15 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string line1 := fmt.Sprintf(" %s %s %s %s", blit.Badge(label, color, true), - s.styles.DetailRepo.Render(repo), - s.styles.DetailActor.Render(actor), - s.styles.DetailTime.Render(t), + s.styles.TextBold.Render(repo), + s.styles.Text.Render(actor), + s.styles.Muted.Render(t), ) detail := blit.Truncate(ev.Detail(), s.width-20) urlHint := "" if url := ev.URL(); url != "" { - urlHint = s.styles.DetailTime.Render(" ↵ open") + urlHint = s.styles.Muted.Render(" ↵ open") } line2 := " " + s.styles.Detail.Render(detail) + urlHint @@ -445,7 +438,8 @@ func (s *EventStream) SetFocused(f bool) { // Tabs → EventStream → Table. func (s *EventStream) SetTheme(t blit.Theme) { s.theme = t - s.styles = NewStyles(t) + s.styles = blit.ThemeStyles(t) + s.panel = NewPanelStyles(t) s.table.SetTheme(t) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 80551db..5ab10e7 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -1,100 +1,83 @@ package ui import ( - "github.com/charmbracelet/lipgloss" blit "github.com/blitui/blit" ) -// Styles holds all UI styles derived from the current theme. -// Rebuild via NewStyles whenever the theme changes. -type Styles struct { - Title lipgloss.Style - Subtitle lipgloss.Style - Detail lipgloss.Style - - PanelRepo lipgloss.Style - PanelDim lipgloss.Style - PanelClean lipgloss.Style - PanelDirty lipgloss.Style - PanelWarn lipgloss.Style - PanelCIFail lipgloss.Style - - DetailRepo lipgloss.Style - DetailActor lipgloss.Style - DetailTime lipgloss.Style +// PanelStyles holds status-panel-specific styles that extend blit.Styles. +// These are derived from the theme and rebuilt on theme change. +type PanelStyles struct { + Repo blit.Style + Dim blit.Style + Clean blit.Style + Dirty blit.Style + Warn blit.Style + CIFail blit.Style } -// NewStyles constructs a full Styles set from a blit.Theme. -func NewStyles(t blit.Theme) Styles { - return Styles{ - Title: lipgloss.NewStyle().Bold(true).Foreground(t.Text).PaddingLeft(1), - Subtitle: lipgloss.NewStyle().Foreground(t.Muted).PaddingLeft(1), - Detail: lipgloss.NewStyle().Foreground(t.Muted), - - PanelRepo: lipgloss.NewStyle().Bold(true).Foreground(t.Accent), - PanelDim: lipgloss.NewStyle().Foreground(t.Muted), - PanelClean: lipgloss.NewStyle().Foreground(t.Positive), - PanelDirty: lipgloss.NewStyle().Foreground(t.Warn), - PanelWarn: lipgloss.NewStyle().Foreground(t.Color("issue", t.Warn)), - PanelCIFail: lipgloss.NewStyle().Foreground(t.Negative), - - DetailRepo: lipgloss.NewStyle().Bold(true).Foreground(t.Text), - DetailActor: lipgloss.NewStyle().Foreground(t.Text), - DetailTime: lipgloss.NewStyle().Foreground(t.Muted), +// NewPanelStyles constructs panel-specific styles from a blit.Theme. +func NewPanelStyles(t blit.Theme) PanelStyles { + return PanelStyles{ + Repo: blit.NewStyle().Bold(true).Foreground(t.Accent), + Dim: blit.NewStyle().Foreground(t.Muted), + Clean: blit.NewStyle().Foreground(t.Positive), + Dirty: blit.NewStyle().Foreground(t.Warn), + Warn: blit.NewStyle().Foreground(t.SemanticColor("issue", t.Warn)), + CIFail: blit.NewStyle().Foreground(t.Negative), } } -// EventColor returns the color for a given event type, derived from theme tokens. -func EventColor(eventType string, theme blit.Theme) lipgloss.Color { +// EventColor returns the color for a given event type using theme semantic colors. +func EventColor(eventType string, theme blit.Theme) blit.Color { switch eventType { case "LocalPushEvent": - return theme.Color("local", theme.Accent) + return theme.SemanticColor("local", theme.Accent) case "PushEvent": - return theme.Color("create", theme.Positive) + return theme.SemanticColor("create", theme.Positive) case "PullRequestEvent": return theme.Accent case "PullRequestReviewEvent", "PullRequestReviewCommentEvent": - return theme.Color("review", theme.Cursor) + return theme.SemanticColor("review", theme.Cursor) case "IssueCommentEvent": - return theme.Color("comment", theme.Muted) + return theme.SemanticColor("comment", theme.Muted) case "IssuesEvent": - return theme.Color("issue", theme.Warn) + return theme.SemanticColor("issue", theme.Warn) case "CreateEvent": - return theme.Color("create", theme.Positive) + return theme.SemanticColor("create", theme.Positive) case "DeleteEvent": - return theme.Color("delete", theme.Negative) + return theme.SemanticColor("delete", theme.Negative) case "ReleaseEvent": - return theme.Color("release", theme.Flash) + return theme.SemanticColor("release", theme.Flash) case "MemberEvent": - return theme.Color("comment", theme.Muted) + return theme.SemanticColor("comment", theme.Muted) default: return theme.Muted } } -// LabelColor maps a display label back to its themed color. -func LabelColor(label string, theme blit.Theme) lipgloss.Color { +// LabelColor maps a display label back to its themed color using semantic colors. +func LabelColor(label string, theme blit.Theme) blit.Color { switch label { case "LOCAL": - return theme.Color("local", theme.Accent) + return theme.SemanticColor("local", theme.Accent) case "PUSH": - return theme.Color("create", theme.Positive) + return theme.SemanticColor("create", theme.Positive) case "PR": return theme.Accent case "REVIEW": - return theme.Color("review", theme.Cursor) + return theme.SemanticColor("review", theme.Cursor) case "COMMENT": - return theme.Color("comment", theme.Muted) + return theme.SemanticColor("comment", theme.Muted) case "ISSUE": - return theme.Color("issue", theme.Warn) + return theme.SemanticColor("issue", theme.Warn) case "CREATE": - return theme.Color("create", theme.Positive) + return theme.SemanticColor("create", theme.Positive) case "DELETE": - return theme.Color("delete", theme.Negative) + return theme.SemanticColor("delete", theme.Negative) case "RELEASE": - return theme.Color("release", theme.Flash) + return theme.SemanticColor("release", theme.Flash) case "STAR", "FORK": - return theme.Color("comment", theme.Muted) + return theme.SemanticColor("comment", theme.Muted) default: return theme.Muted }