diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index f82c14f..a0276f0 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -133,22 +133,25 @@ func main() { Group: "Polling", Hint: "How often to poll GitHub for new events (min 5)", Get: func() string { return strconv.Itoa(cfg.Interval) }, - Set: func(v string) error { + Validate: func(v string) error { n, err := strconv.Atoi(v) if err != nil || n < 5 { return fmt.Errorf("must be a number >= 5") } - cfg.Interval = n - config.Save(cfg) 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 "" }, - Set: func(v string) error { + Validate: func(v string) error { v = strings.TrimSpace(v) if v == "" || !strings.Contains(v, "/") { return fmt.Errorf("must be owner/repo format") @@ -158,33 +161,38 @@ func main() { return fmt.Errorf("repo already exists") } } - cfg.RepoEntries = append(cfg.RepoEntries, config.RepoEntry{Name: v}) - config.Save(cfg) 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 "" }, - Set: func(v string) error { + Validate: func(v string) error { v = strings.TrimSpace(v) - filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries)) - found := false for _, r := range cfg.RepoEntries { if r.Name == v { - found = true - continue + return nil } - filtered = append(filtered, r) } - if !found { - return fmt.Errorf("repo not found") + 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 - config.Save(cfg) - return nil + return config.Save(cfg) }, }, }) @@ -193,7 +201,7 @@ func main() { // deadlocking — bubbletea's p.msgs is unbuffered, and Signal.Set triggers // bus.schedule → p.Send from the UI goroutine which would block forever. leftSig := blit.NewSignal( - " ? help s sort t type c config D debug p pause r refresh 1-5 tab 0 clear") + " ? help s sort t type c config D debug p pause r refresh y copy 1-5 tab 0 clear") rightSig := blit.NewSignal[string]("") updateStatusRight := func() { var parts []string @@ -326,7 +334,8 @@ func main() { }, }) - app := blit.NewApp( + var app *blit.App + app = blit.NewApp( blit.WithTheme(resolveTheme(cfg.Theme)), blit.WithLayout(&blit.DualPane{ Main: tabs, @@ -340,42 +349,84 @@ func main() { }), blit.WithStatusBarSignal(leftSig, rightSig), blit.WithHelp(), - blit.WithOverlay("Settings", "c", configEditor), - blit.WithOverlay("Debug", "D", debugOverlay), - blit.WithOverlay("Detail", "", detailOverlay), - blit.WithOverlay("Theme", "ctrl+t", themePicker), - blit.WithOverlay("Command", ":", cmdBar), + blit.WithSlotOverlay("Settings", "c", configEditor), + blit.WithSlotOverlay("Debug", "D", debugOverlay), + blit.WithSlotOverlay("Detail", "", detailOverlay), + blit.WithSlotOverlay("Theme", "ctrl+t", themePicker), + blit.WithSlotOverlay("Command", ":", cmdBar), // Global keybindings blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", - Handler: func() { stream.TogglePause(); updateStatusRight() }, + Handler: func() { + stream.TogglePause() + updateStatusRight() + if stream.IsPaused() { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Paused", Duration: 3 * time.Second}) + } else { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Resumed", Duration: 3 * time.Second}) + } + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "r", Label: "Refresh now", Group: "CONTROLS", - Handler: func() { stream.ForceRefresh() }, + Handler: func() { + stream.ForceRefresh() + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Refreshing…", Duration: 3 * time.Second}) + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "s", Label: "Toggle sort", Group: "CONTROLS", - Handler: func() { stream.ToggleSort(); updateStatusRight() }, + Handler: func() { + stream.ToggleSort() + updateStatusRight() + if stream.IsNewestFirst() { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Newest first", Duration: 3 * time.Second}) + } else { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Oldest first", Duration: 3 * time.Second}) + } + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "t", Label: "Type filter →", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(true); updateStatusRight() }, + Handler: func() { + stream.CycleTypeFilter(true) + updateStatusRight() + if f := stream.TypeFilter(); f != "" { + ev := github.Event{Type: f} + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filter: " + ev.Label(), Duration: 3 * time.Second}) + } else { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filter: All", Duration: 3 * time.Second}) + } + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "T", Label: "Type filter ←", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(false); updateStatusRight() }, + Handler: func() { + stream.CycleTypeFilter(false) + updateStatusRight() + if f := stream.TypeFilter(); f != "" { + ev := github.Event{Type: f} + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filter: " + ev.Label(), Duration: 3 * time.Second}) + } else { + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filter: All", Duration: 3 * time.Second}) + } + }, }), blit.WithKeyBind(blit.KeyBind{ Key: "0", Label: "Clear filters", Group: "FILTER", - Handler: func() { stream.ClearFilters(); updateStatusRight() }, + Handler: func() { + stream.ClearFilters() + updateStatusRight() + app.Send(blit.ToastMsg{Severity: blit.SeverityInfo, Title: "Filters cleared", Duration: 3 * time.Second}) + }, }), blit.WithMouseSupport(), - blit.WithTickInterval(time.Second), + blit.WithTickInterval(200*time.Millisecond), blit.WithAutoUpdate(updatewire.New(version)), + blit.WithDevConsole(), + blit.WithAnimations(true), ) - if err := app.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -407,6 +458,7 @@ Keybindings (in TUI): Tab Switch focus Enter Event detail o Open in browser + y Copy URL to clipboard Config: ~/.config/gitstream/config.yaml `) @@ -414,12 +466,20 @@ Config: ~/.config/gitstream/config.yaml func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { ev := de.Event - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")) - color := ui.EventColor(ev.Type) + + // Breadcrumb trail: gitstream > repo > event type + bc := blit.NewBreadcrumbs([]string{"gitstream", ev.Repo.Name, ev.Label()}) + bc.SetSize(w, 1) + bc.SetTheme(theme) + + labelStyle := lipgloss.NewStyle().Foreground(theme.Muted) + valStyle := lipgloss.NewStyle().Foreground(theme.Text) + color := ui.EventColor(ev.Type, theme) typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) lines := []string{ + bc.View(), + "", labelStyle.Render("Type: ") + typeStyle.Render(ev.Label()), labelStyle.Render("Repo: ") + valStyle.Render(ev.Repo.Name), labelStyle.Render("Actor: ") + valStyle.Render(ev.Actor.Login), @@ -435,7 +495,6 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { detail := ev.Detail() if detail != "" { lines = append(lines, labelStyle.Render("Detail:")) - // Word-wrap detail to width maxW := w - 2 if maxW < 20 { maxW = 20 @@ -453,6 +512,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) for _, c := range ev.Payload.Commits { sha := c.SHA if len(sha) > 7 { @@ -466,7 +526,7 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { if maxMsg > 0 && len(msg) > maxMsg { msg = msg[:maxMsg-1] + "…" } - lines = append(lines, " "+lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).Render(sha)+" "+valStyle.Render(msg)) + lines = append(lines, " "+shaStyle.Render(sha)+" "+valStyle.Render(msg)) } } @@ -508,9 +568,11 @@ 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) for _, f := range cd.Files { - adds := lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Render(fmt.Sprintf("+%d", f.Additions)) - dels := lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")).Render(fmt.Sprintf("-%d", f.Deletions)) + adds := addStyle.Render(fmt.Sprintf("+%d", f.Additions)) + dels := delStyle.Render(fmt.Sprintf("-%d", f.Deletions)) lines = append(lines, " "+adds+" "+dels+" "+valStyle.Render(f.Filename)) } } @@ -519,12 +581,22 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string { } func resolveTheme(name string) blit.Theme { - if name == "" { - return blit.DefaultTheme() + if name != "" { + presets := blit.Presets() + if t, ok := presets[name]; ok { + return t + } } - presets := blit.Presets() - if t, ok := presets[name]; ok { - return t + t := blit.DefaultTheme() + t.Extra = map[string]lipgloss.Color{ + "info": "#06b6d4", + "create": "#22c55e", + "delete": "#ef4444", + "review": "#a855f7", + "comment": "#6b7280", + "issue": "#eab308", + "release": "#f97316", + "local": "#a78bfa", } - return blit.DefaultTheme() + return t } diff --git a/go.mod b/go.mod index 9834aa7..d7d336b 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,10 @@ module github.com/moneycaringcoder/gitstream-tui go 1.26.1 require ( - github.com/blitui/blit v0.1.0 + github.com/blitui/blit v0.1.2 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - gopkg.in/yaml.v3 v3.0.1 + github.com/charmbracelet/x/ansi v0.11.6 ) require ( @@ -22,7 +22,6 @@ require ( 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 - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/conpty v0.1.0 // indirect github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect @@ -62,4 +61,5 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index eae00bf..065e68b 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.0 h1:AfQnTZQHTi7YkRfYUiDfl3dmkuPtSMEmZCilDAuRO0Y= -github.com/blitui/blit v0.1.0/go.mod h1:OQ3XhjGhDneebNJs/ldXqRRXKG1H3+XrYWefdHDD+LY= +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/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 62d7bf6..3e94169 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,9 +3,8 @@ package config import ( "fmt" "os" - "path/filepath" - "gopkg.in/yaml.v3" + blit "github.com/blitui/blit" ) // RepoEntry represents a watched repo with an optional local path override. @@ -68,24 +67,28 @@ func (c *Config) ExplicitPaths() map[string]string { } func DefaultPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "gitstream", "config.yaml") + p, err := blit.DefaultConfigPath("gitstream") + if err != nil { + home, _ := os.UserHomeDir() + return home + "/.config/gitstream/config.yaml" + } + return p } func Load() (*Config, error) { path := DefaultPath() - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("no config found at %s - run 'gitstream add owner/repo' to get started", path) - } + var cfg Config + if err := blit.LoadYAML(path, &cfg); err != nil { return nil, err } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("invalid config: %w", 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) + } } if cfg.Interval <= 0 { @@ -96,21 +99,13 @@ func Load() (*Config, error) { } func Save(cfg *Config) error { - path := DefaultPath() - - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - - data, err := yaml.Marshal(cfg) - if err != nil { - return err - } - - return os.WriteFile(path, data, 0o644) + return blit.SaveYAML(DefaultPath(), cfg) } func AddRepo(repo string) error { + if _, err := blit.EnsureConfigDir("gitstream"); err != nil { + return fmt.Errorf("create config dir: %w", err) + } cfg, err := Load() if err != nil { cfg = &Config{Interval: 30} diff --git a/internal/ui/debug.go b/internal/ui/debug.go index 301e9e6..3eb79a3 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -2,16 +2,19 @@ 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" ) // DebugOverlay shows API stats and recent log entries using blit.LogViewer. -// Implements blit.Component and blit.Overlay. +// Implements blit.Component and blit.Overlay. Renders as a full-screen modal. type DebugOverlay struct { logViewer *blit.LogViewer debugLog *DebugLog @@ -19,6 +22,7 @@ type DebugOverlay struct { width int height int focused bool + theme blit.Theme } func NewDebugOverlay(debugLog *DebugLog) *DebugOverlay { @@ -27,6 +31,7 @@ func NewDebugOverlay(debugLog *DebugLog) *DebugOverlay { return &DebugOverlay{ logViewer: lv, debugLog: debugLog, + theme: blit.DefaultTheme(), } } @@ -40,20 +45,105 @@ func (d *DebugOverlay) Update(msg tea.Msg, ctx blit.Context) (blit.Component, te return d, cmd } +// View renders the debug overlay as a full-screen bordered modal. func (d *DebugOverlay) View() string { + th := d.theme + + // Available text area inside border(2) + padding(2) + textW := d.width - 4 + if textW < 20 { + textW = 20 + } + // Total content lines available inside border(2) + maxLines := d.height - 2 + if maxLines < 6 { + maxLines = 6 + } + + // Render the stats section first so we know how tall it is + statsStr := d.renderStats(textW) + statsLines := strings.Count(statsStr, "\n") + 1 + + // Give the remaining lines to the log viewer (minimum 4) + // Account for divider (2 lines: divider + blank) + lvLines := maxLines - statsLines - 2 + if lvLines < 4 { + lvLines = 4 + } + d.logViewer.SetSize(textW, lvLines) + + // Assemble: stats + divider + log viewer var b strings.Builder + b.WriteString(statsStr) + b.WriteString("\n") + b.WriteString(blit.Divider(textW, th)) + b.WriteString("\n") + b.WriteString(d.logViewer.View()) + content := b.String() - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#ffffff")). - PaddingLeft(1).Render("─── DEBUG LOG ───") - b.WriteString(title + "\n\n") + // Truncate content to maxLines so it can't overflow the box + cLines := strings.Split(content, "\n") + if len(cLines) > maxLines { + cLines = cLines[:maxLines] + } + content = strings.Join(cLines, "\n") + // Render the bordered box + title := lipgloss.NewStyle(). + Bold(true). + Foreground(th.Accent). + Render(" Debug Console ") + + box := lipgloss.NewStyle(). + Width(textW + 2). // +2 for padding(0,1) + Border(lipgloss.RoundedBorder()). + BorderForeground(th.Border). + Foreground(th.Text). + Padding(0, 1) + + rendered := box.Render(content) + + // Inject title into the top border + lines := strings.Split(rendered, "\n") + if len(lines) > 0 { + borderWidth := lipgloss.Width(lines[0]) + titleWidth := lipgloss.Width(title) + if titleWidth+4 < borderWidth { + pos := (borderWidth - titleWidth) / 2 + runes := []rune(lines[0]) + if pos+titleWidth <= len(runes) { + lines[0] = string(runes[:pos]) + title + string(runes[pos+titleWidth:]) + } + } + } + + // Hard-clamp to terminal dimensions and pad to fill exactly + if len(lines) > d.height { + lines = lines[:d.height] + } + for len(lines) < d.height { + lines = append(lines, "") + } + for i, line := range lines { + if ansi.StringWidth(line) > d.width { + lines[i] = ansi.Truncate(line, d.width, "") + } + } + + return strings.Join(lines, "\n") +} + +// 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(lipgloss.Color("#3b82f6")).Bold(true) - dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")) - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")) + 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(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 { @@ -67,28 +157,79 @@ func (d *DebugOverlay) View() string { b.WriteString(dim.Render(fmt.Sprintf(" Last fetch: %s ago", ago)) + "\n") } - // Repo health dots - if len(stats.RepoHealth) > 0 { + // 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") - green := lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")) - red := lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")) - for repo, h := range stats.RepoHealth { - dot := green.Render("●") - if !h.LastSuccess { - dot = red.Render("●") + 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", dot, dim.Render(repo)) + "\n") + b.WriteString(fmt.Sprintf(" %s %s", badge, dim.Render(repo)) + "\n") } } - // Rate limit + // Cap chart width: at most 60 cols or half the text area, whichever is larger + chartW := textW / 2 + if chartW < 30 { + chartW = 30 + } + if chartW > 60 { + chartW = 60 + } + if chartW > textW-2 { + chartW = textW - 2 + } + + // Rate limit gauge if stats.RateLimit > 0 { - b.WriteString(dim.Render(fmt.Sprintf(" Rate limit: %d/%d", stats.RateRemain, stats.RateLimit)) + "\n") + 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") } - b.WriteString("\n") - b.WriteString(d.logViewer.View()) + // Per-repo bar chart + if len(repoKeys) > 0 { + b.WriteString("\n") + b.WriteString(statsHeader.Render("Events by Repo") + "\n") + var data []float64 + var labels []string + for _, repo := range repoKeys { + h := stats.RepoHealth[repo] + short := repo + if i := strings.LastIndex(repo, "/"); i >= 0 { + short = repo[i+1:] + } + streak := float64(h.FailStreak) + if h.LastSuccess { + streak = 1 + } + data = append(data, streak) + labels = append(labels, short) + } + bar := charts.NewBar(data, labels, true) + bar.SetTheme(th) + bar.SetSize(chartW, len(labels)+1) + b.WriteString(" " + bar.View()) + } return b.String() } @@ -100,18 +241,22 @@ func (d *DebugOverlay) KeyBindings() []blit.KeyBind { func (d *DebugOverlay) SetSize(w, h int) { d.width = w d.height = h - // Reserve lines for the stats header; give the rest to the log viewer - headerLines := 12 - lvHeight := h - headerLines + // LogViewer size is computed dynamically in View() based on stats height, + // but set a reasonable default here for initial sizing. + contentW := w - 4 + if contentW < 20 { + contentW = 20 + } + lvHeight := h - 22 if lvHeight < 4 { lvHeight = 4 } - d.logViewer.SetSize(w, lvHeight) + d.logViewer.SetSize(contentW, lvHeight) } -func (d *DebugOverlay) Focused() bool { return d.focused } -func (d *DebugOverlay) SetFocused(f bool) { d.focused = f; d.logViewer.SetFocused(f) } -func (d *DebugOverlay) SetTheme(t blit.Theme) { d.logViewer.SetTheme(t) } -func (d *DebugOverlay) IsActive() bool { return d.active } -func (d *DebugOverlay) SetActive(v bool) { d.active = v } -func (d *DebugOverlay) Close() { d.active = false } +func (d *DebugOverlay) Focused() bool { return d.focused } +func (d *DebugOverlay) SetFocused(f bool) { d.focused = f; d.logViewer.SetFocused(f) } +func (d *DebugOverlay) SetTheme(t blit.Theme) { d.theme = t; d.logViewer.SetTheme(t) } +func (d *DebugOverlay) IsActive() bool { return d.active } +func (d *DebugOverlay) SetActive(v bool) { d.active = v } +func (d *DebugOverlay) Close() { d.active = false } diff --git a/internal/ui/debuglog.go b/internal/ui/debuglog.go index bf0517e..bc481a2 100644 --- a/internal/ui/debuglog.go +++ b/internal/ui/debuglog.go @@ -8,28 +8,9 @@ import ( blit "github.com/blitui/blit" ) -const maxLogEntries = 200 - -// LogLevel indicates severity. -type LogLevel int - -const ( - LogInfo LogLevel = iota - LogWarn - LogError -) - -// LogEntry is a single debug log entry. -type LogEntry struct { - Time time.Time - Level LogLevel - Message string -} - -// DebugLog is a thread-safe circular log buffer. +// DebugLog is a thread-safe log that writes directly to a blit.LogViewer. type DebugLog struct { mu sync.Mutex - entries []LogEntry stats FetchStats logViewer *blit.LogViewer } @@ -54,55 +35,33 @@ type FetchStats struct { } func NewDebugLog() *DebugLog { - return &DebugLog{ - entries: make([]LogEntry, 0, maxLogEntries), - } + return &DebugLog{} } -// SetLogViewer wires a blit.LogViewer so that new log entries are also appended to it. +// SetLogViewer wires a blit.LogViewer so that new log entries are appended to it. func (d *DebugLog) SetLogViewer(lv *blit.LogViewer) { d.mu.Lock() defer d.mu.Unlock() d.logViewer = lv } -// mapLevel converts a ui.LogLevel to a blit.LogLevel. -func mapLevel(l LogLevel) blit.LogLevel { - switch l { - case LogWarn: - return blit.LogWarn - case LogError: - return blit.LogError - default: - return blit.LogInfo - } -} - -func (d *DebugLog) Log(level LogLevel, format string, args ...interface{}) { +func (d *DebugLog) Log(level blit.LogLevel, format string, args ...interface{}) { d.mu.Lock() defer d.mu.Unlock() now := time.Now() msg := fmt.Sprintf(format, args...) - d.entries = append(d.entries, LogEntry{ - Time: now, - Level: level, - Message: msg, - }) - if len(d.entries) > maxLogEntries { - d.entries = d.entries[len(d.entries)-maxLogEntries:] - } if d.logViewer != nil { d.logViewer.Append(blit.LogLine{ - Level: mapLevel(level), + Level: level, Timestamp: now, Message: msg, }) } } -func (d *DebugLog) Info(format string, args ...interface{}) { d.Log(LogInfo, format, args...) } -func (d *DebugLog) Warn(format string, args ...interface{}) { d.Log(LogWarn, format, args...) } -func (d *DebugLog) Error(format string, args ...interface{}) { d.Log(LogError, format, args...) } +func (d *DebugLog) Info(format string, args ...interface{}) { d.Log(blit.LogInfo, format, args...) } +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...) } func (d *DebugLog) RecordFetch(repo string, success bool, eventCount int, usingCache bool) { d.mu.Lock() @@ -143,12 +102,3 @@ func (d *DebugLog) GetStats() FetchStats { defer d.mu.Unlock() return d.stats } - -func (d *DebugLog) GetEntries() []LogEntry { - d.mu.Lock() - defer d.mu.Unlock() - cp := make([]LogEntry, len(d.entries)) - copy(cp, d.entries) - return cp -} - diff --git a/internal/ui/harness_test.go b/internal/ui/harness_test.go new file mode 100644 index 0000000..290bde8 --- /dev/null +++ b/internal/ui/harness_test.go @@ -0,0 +1,244 @@ +package ui + +import ( + "testing" + + blit "github.com/blitui/blit" + "github.com/blitui/blit/btest" +) + +// TestHarness_EventStreamRender uses the fluent Harness API to verify +// initial rendering of the event stream. +func TestHarness_EventStreamRender(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 120, 40) + defer h.Done() + + // Inject events + h.Send(eventsMsg{events: testEvents()}) + + h.Expect("gitstream"). + Expect("alice"). + Expect("bob"). + Expect("charlie"). + Expect("repo-a"). + Expect("repo-b") +} + +// TestHarness_CursorNavigation uses chained Keys + Expect calls. +func TestHarness_CursorNavigation(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 120, 40) + defer h.Done() + + h.Send(eventsMsg{events: testEvents()}) + + h.Keys("down", "down", "up"). + Expect("alice"). + Expect("charlie") +} + +// TestHarness_EventTypes verifies that event type badges render correctly +// for each injected event type. +func TestHarness_EventTypes(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 120, 40) + defer h.Done() + + h.Send(eventsMsg{events: testEvents()}) + + h.Expect("PUSH"). + Expect("PR"). + Expect("ISSUE") +} + +// TestHarness_SnapshotEmptyStream captures a golden snapshot of the empty state +// (no events injected), which has no dynamic timestamps. +func TestHarness_SnapshotEmptyStream(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 80, 20) + defer h.Done() + + h.Snapshot("empty_stream") +} + +// TestHarness_SnapshotAfterResize captures a snapshot at a smaller viewport. +func TestHarness_SnapshotAfterResize(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 80, 20) + defer h.Done() + + h.Resize(60, 15) + h.Snapshot("resized_stream") +} + +// testFullApp builds a full app mirroring the real gitstream layout (Tabs + DualPane +// + StatusBar). Reusable across tests that need the production component tree. +func testFullApp() *blit.App { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + tabs := blit.NewTabs([]blit.TabItem{ + {Title: "All", Glyph: "◉", Content: stream}, + {Title: "Pushes", Glyph: "↑"}, + {Title: "PRs", Glyph: "⎇"}, + }, blit.TabsOpts{}) + + return blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: tabs, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) +} + +// TestHarness_SnapshotWithTabs captures a golden snapshot of the full app layout +// (Tabs + DualPane + StatusBar) using btest.SnapshotApp. This catches tab bar +// height regressions that the bare-EventStream snapshots would miss. +func TestHarness_SnapshotWithTabs(t *testing.T) { + btest.SnapshotApp(t, testFullApp(), 80, 20, "tabs_empty") +} + +// TestHarness_AppResize uses NewAppHarness for an interactive test of the full +// app layout at multiple viewport sizes. +func TestHarness_AppResize(t *testing.T) { + h := btest.NewAppHarness(t, testFullApp(), 80, 20) + defer h.Done() + + h.Expect("◉ All"). + Expect("↑ Pushes"). + Expect("gitstream") + + h.Resize(60, 15). + Expect("gitstream"). + Snapshot("tabs_resized") +} + +// TestHarness_Resize verifies the layout adapts to terminal size changes. +func TestHarness_Resize(t *testing.T) { + cfg := testConfig() + debugLog := NewDebugLog() + stream := NewEventStream(cfg, debugLog) + panel := NewStatusPanel() + + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ + Main: stream, + Side: panel, + SideWidth: 32, + MinMainWidth: 40, + SideRight: true, + }), + blit.WithStatusBar( + func() string { return " test" }, + func() string { return "test " }, + ), + ) + + h := btest.NewHarness(t, app.Model(), 120, 40) + defer h.Done() + + h.Send(eventsMsg{events: testEvents()}) + h.Resize(60, 20). + Expect("gitstream"). + Expect("alice") +} diff --git a/internal/ui/panel.go b/internal/ui/panel.go index 5905ef7..89e51bf 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -22,16 +22,22 @@ type StatusPanel struct { listView *blit.ListView[panelLine] focused bool width int + theme blit.Theme + styles Styles sections map[string]*blit.CollapsibleSection headerMap map[int]string // line index → repo remote } func NewStatusPanel() *StatusPanel { + th := blit.DefaultTheme() p := &StatusPanel{ sections: make(map[string]*blit.CollapsibleSection), headerMap: make(map[int]string), + theme: th, + styles: NewStyles(th), } p.listView = blit.NewListView(blit.ListViewOpts[panelLine]{ + EmptyText: "No local repos found", RenderItem: func(item panelLine, idx int, isCursor bool, theme blit.Theme) string { return item.text }, @@ -89,6 +95,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.listView.SetTheme(t) } @@ -98,7 +106,7 @@ func (p *StatusPanel) rebuildContent() { if len(p.repoStatus) == 0 { lines = append(lines, panelLine{text: ""}) - lines = append(lines, panelLine{text: PanelDimStyle.Render("Scanning for repos...")}) + lines = append(lines, panelLine{text: p.styles.PanelDim.Render("Scanning for repos...")}) p.listView.SetItems(lines) return } @@ -126,23 +134,23 @@ func (p *StatusPanel) rebuildContent() { indicator = "▶" } p.headerMap[len(lines)] = s.Remote - lines = append(lines, panelLine{text: PanelRepoStyle.Render(indicator + " " + short)}) + lines = append(lines, panelLine{text: p.styles.PanelRepo.Render(indicator + " " + short)}) if !sec.Collapsed { if s.Error != nil { - lines = append(lines, panelLine{text: PanelDimStyle.Render(" error")}) + lines = append(lines, panelLine{text: p.styles.PanelDim.Render(" error")}) } else { - lines = append(lines, panelLine{text: PanelDimStyle.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) + lines = append(lines, panelLine{text: p.styles.PanelDim.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) if s.Uncommitted == 0 && s.Unpushed == 0 { - lines = append(lines, panelLine{text: PanelCleanStyle.Render(" ✓ clean")}) + lines = append(lines, panelLine{text: p.styles.PanelClean.Render(" ✓ clean")}) } else { if s.Uncommitted > 0 { - lines = append(lines, panelLine{text: PanelDirtyStyle.Render( + lines = append(lines, panelLine{text: p.styles.PanelDirty.Render( fmt.Sprintf(" ● %d uncommitted", s.Uncommitted))}) } if s.Unpushed > 0 { - lines = append(lines, panelLine{text: PanelWarnStyle.Render( + lines = append(lines, panelLine{text: p.styles.PanelWarn.Render( fmt.Sprintf(" ↑ %d unpushed", s.Unpushed))}) for _, c := range s.UnpushedCommits { msg := c.Message @@ -153,29 +161,29 @@ func (p *StatusPanel) rebuildContent() { if len(msg) > maxLen { msg = msg[:maxLen-1] + "…" } - lines = append(lines, panelLine{text: PanelDimStyle.Render( + lines = append(lines, panelLine{text: p.styles.PanelDim.Render( fmt.Sprintf(" %s %s", c.SHA, msg))}) } } } if !s.HasUpstream { - lines = append(lines, panelLine{text: PanelDimStyle.Render(" ⚠ no upstream")}) + lines = append(lines, panelLine{text: p.styles.PanelDim.Render(" ⚠ no upstream")}) } if s.CI != nil { var ciLine string switch s.CI.Conclusion { case "success": - ciLine = PanelCleanStyle.Render(" ✓ CI passed") + ciLine = p.styles.PanelClean.Render(" ✓ CI passed") case "failure": - ciLine = PanelCIFailStyle.Render(" ✗ CI failed") + ciLine = p.styles.PanelCIFail.Render(" ✗ CI failed") case "cancelled": - ciLine = PanelDimStyle.Render(" ○ CI cancelled") + ciLine = p.styles.PanelDim.Render(" ○ CI cancelled") default: if s.CI.Status == "in_progress" { - ciLine = PanelWarnStyle.Render(" ◌ CI running") + ciLine = p.styles.PanelWarn.Render(" ◌ CI running") } else { - ciLine = PanelDimStyle.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) + ciLine = p.styles.PanelDim.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) } } lines = append(lines, panelLine{text: ciLine}) diff --git a/internal/ui/render.go b/internal/ui/render.go index 4dae6d0..dbfdeec 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -30,30 +30,3 @@ func eventToRow(de DisplayEvent) blit.Row { } } -// renderEventLine renders a single event as a styled string (legacy). -func renderEventLine(ev github.Event, now time.Time) string { - t := ev.CreatedAt.Local().Format("15:04:05") - rel := blit.RelativeTime(ev.CreatedAt, now) - timeStr := fmt.Sprintf("%s %s", t, rel) - - label := ev.Label() - detail := ev.Detail() - actor := ev.Actor.Login - repo := ev.ShortRepo() - url := ev.URL() - - detailRendered := DetailStyle.Render(detail) - if url != "" { - detailRendered = blit.OSC8Link(url, detailRendered) - } - - line := fmt.Sprintf("%s %s %s %s %s", - TimeStyle.Render(timeStr), - RepoStyle.Render(repo), - blit.Badge(label, EventColor(ev.Type), true), - ActorStyle.Render(actor), - detailRendered, - ) - - return line -} diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go index ea876df..25696e4 100644 --- a/internal/ui/session_test.go +++ b/internal/ui/session_test.go @@ -62,3 +62,10 @@ func TestSession_TypeFilter(t *testing.T) { r.Key("t").Key("t").Key("0") }) } + +// TestSession_SortToggle covers toggling between oldest-first and newest-first. +func TestSession_SortToggle(t *testing.T) { + recordAndVerify(t, "sort_toggle", func(r *btest.SessionRecorder) { + r.Key("s").Key("s") + }) +} diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 5257076..febb728 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -51,6 +51,8 @@ type EventStream struct { focused bool width int + theme blit.Theme + styles Styles epmWindow []float64 // events-per-minute rolling window (last 30 points) lastEPMTick time.Time @@ -60,6 +62,7 @@ type EventStream struct { } func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { + th := blit.DefaultTheme() s := &EventStream{ cfg: cfg, debugLog: debugLog, @@ -68,6 +71,8 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { allEvents: make([]DisplayEvent, 0, 256), filteredEvents: make([]DisplayEvent, 0, 256), knownRepos: append([]string{}, cfg.Repos()...), + theme: th, + styles: NewStyles(th), } columns := []blit.Column{ @@ -85,10 +90,10 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { return "" } switch colIdx { - case 0: // Time - dim gray - return lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render(row[colIdx]) + case 0: // Time + return lipgloss.NewStyle().Foreground(theme.Muted).Render(row[colIdx]) case 2: // Type - colored badge - return blit.Badge(row[colIdx], LabelColor(row[colIdx]), true) + return blit.Badge(row[colIdx], LabelColor(row[colIdx], theme), true) default: return row[colIdx] } @@ -97,7 +102,7 @@ func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { if idx < len(s.filteredEvents) { de := s.filteredEvents[idx] if !de.AddedAt.IsZero() && time.Now().Before(de.AddedAt.Add(flashDuration)) { - st := lipgloss.NewStyle().Background(lipgloss.Color("#1a2a1a")) + st := lipgloss.NewStyle().Background(theme.Flash) return &st } } @@ -159,6 +164,20 @@ func (s *EventStream) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea return s, blit.Consumed() } } + // 'y' copies event URL to clipboard + if msg.String() == "y" { + idx := s.table.CursorIndex() + if idx >= 0 && idx < len(s.filteredEvents) { + if url := s.filteredEvents[idx].Event.URL(); url != "" { + return s, tea.Batch( + blit.CopyToClipboardCmd(url), + blit.ToastSuccess("Copied", url), + blit.Consumed(), + ) + } + } + return s, blit.Consumed() + } // Let table handle its own keys (cursor, search, etc.) comp, cmd := s.table.Update(msg, ctx) _ = comp @@ -247,16 +266,21 @@ func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { var cmds []tea.Cmd if newCount > 0 && !s.isAtNewEdge() { - cmds = append(cmds, blit.ToastCmd(blit.SeverityInfo, "New events", - fmt.Sprintf("%d new events arrived", newCount), 3*time.Second)) + cmds = append(cmds, blit.ToastInfo("New events", + fmt.Sprintf("%d new events arrived", newCount))) } stats := s.debugLog.GetStats() if stats.RateLimit > 0 { ratePct := float64(stats.RateRemain) / float64(stats.RateLimit) * 100 if ratePct < 20 { - cmds = append(cmds, blit.ToastCmd(blit.SeverityWarn, "Rate limit low", - fmt.Sprintf("API rate limit at %.0f%%", ratePct), 5*time.Second)) + cmds = append(cmds, blit.ToastCmd( + blit.SeverityWarn, + "Rate limit low", + fmt.Sprintf("API rate limit at %.0f%% (%d/%d)", ratePct, stats.RateRemain, stats.RateLimit), + 0, + blit.ToastAction{Label: "Pause", Handler: func() { s.poller.TogglePause() }}, + )) } } @@ -319,13 +343,13 @@ func (s *EventStream) View() string { } func (s *EventStream) renderHeader() string { - title := TitleStyle.Render("gitstream") - repoList := SubtitleStyle.Render(fmt.Sprintf("Watching: %s", strings.Join(s.cfg.Repos(), ", "))) + 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 if s.poller.IsPaused() { - statusParts = append(statusParts, lipgloss.NewStyle().Foreground(lipgloss.Color("#eab308")).Render("[PAUSED]")) + statusParts = append(statusParts, lipgloss.NewStyle().Foreground(s.theme.Warn).Render("[PAUSED]")) } else if !s.poller.LastPoll().IsZero() { ago := time.Since(s.poller.LastPoll()).Truncate(time.Second) statusParts = append(statusParts, fmt.Sprintf("Poll %s ago", ago)) @@ -336,25 +360,25 @@ func (s *EventStream) renderHeader() string { if i := strings.LastIndex(repo, "/"); i >= 0 { short = repo[i+1:] } - dot := lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render("○") if h, ok := stats.RepoHealth[repo]; ok { if h.LastSuccess { - dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Render("●") + statusParts = append(statusParts, blit.Badge("●", s.theme.Positive, false)+" "+short) } else if h.UsingCache && h.FailStreak < 10 { - dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#eab308")).Render("●") + statusParts = append(statusParts, blit.Badge("●", s.theme.Warn, false)+" "+short) } else { - dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")).Render("●") + statusParts = append(statusParts, blit.Badge("●", s.theme.Negative, false)+" "+short) } + } else { + statusParts = append(statusParts, blit.Badge("○", s.theme.Muted, false)+" "+short) } - statusParts = append(statusParts, dot+" "+short) } if stats.RateLimit > 0 { ratePct := float64(stats.RateRemain) / float64(stats.RateLimit) * 100 - rateColor := lipgloss.Color("#22c55e") + rateColor := s.theme.Positive if ratePct < 20 { - rateColor = lipgloss.Color("#ef4444") + rateColor = s.theme.Negative } else if ratePct < 50 { - rateColor = lipgloss.Color("#eab308") + rateColor = s.theme.Warn } statusParts = append(statusParts, lipgloss.NewStyle().Foreground(rateColor).Render( fmt.Sprintf("API %d/%d", stats.RateRemain, stats.RateLimit))) @@ -363,7 +387,7 @@ func (s *EventStream) renderHeader() string { spark, _ := blit.Sparkline(s.epmWindow, 30, nil) statusParts = append(statusParts, "Activity: "+spark) } - status := SubtitleStyle.Render(strings.Join(statusParts, " ")) + status := s.styles.Subtitle.Render(strings.Join(statusParts, " ")) return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status) } @@ -376,21 +400,21 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string label := ev.Label() actor := ev.Actor.Login t := ev.CreatedAt.Local().Format("2006-01-02 15:04:05") - color := EventColor(ev.Type) + color := EventColor(ev.Type, theme) line1 := fmt.Sprintf(" %s %s %s %s", blit.Badge(label, color, true), - DetailRepoStyle.Render(repo), - DetailActorStyle.Render(actor), - DetailTimeStyle.Render(t), + s.styles.DetailRepo.Render(repo), + s.styles.DetailActor.Render(actor), + s.styles.DetailTime.Render(t), ) detail := blit.Truncate(ev.Detail(), s.width-20) urlHint := "" if url := ev.URL(); url != "" { - urlHint = DetailTimeStyle.Render(" ↵ open") + urlHint = s.styles.DetailTime.Render(" ↵ open") } - line2 := " " + DetailStyle.Render(detail) + urlHint + line2 := " " + s.styles.Detail.Render(detail) + urlHint return divider + "\n" + line1 + "\n" + line2 } @@ -400,6 +424,7 @@ func (s *EventStream) KeyBindings() []blit.KeyBind { bindings = append(bindings, blit.KeyBind{Key: "enter", Label: "Event detail", Group: "NAVIGATION"}, blit.KeyBind{Key: "o", Label: "Open in browser", Group: "NAVIGATION"}, + blit.KeyBind{Key: "y", Label: "Copy URL", Group: "NAVIGATION"}, ) return bindings } @@ -419,6 +444,8 @@ func (s *EventStream) SetFocused(f bool) { // SetTheme implements blit.Themed so the App's theme propagates through // Tabs → EventStream → Table. func (s *EventStream) SetTheme(t blit.Theme) { + s.theme = t + s.styles = NewStyles(t) s.table.SetTheme(t) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index b1eacd2..80551db 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -1,148 +1,101 @@ package ui -import "github.com/charmbracelet/lipgloss" - -var ( - // Event type colors - ColorPush = lipgloss.Color("#22c55e") // green - ColorPR = lipgloss.Color("#3b82f6") // blue - ColorReview = lipgloss.Color("#a855f7") // purple - ColorComment = lipgloss.Color("#06b6d4") // cyan - ColorIssue = lipgloss.Color("#eab308") // yellow - ColorCreate = lipgloss.Color("#22c55e") // green - ColorDelete = lipgloss.Color("#ef4444") // red - ColorRelease = lipgloss.Color("#f97316") // orange - ColorLocal = lipgloss.Color("#a78bfa") // light purple - ColorDim = lipgloss.Color("#6b7280") // gray - - // Layout styles - TitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - PaddingLeft(1) - - SubtitleStyle = lipgloss.NewStyle(). - Foreground(ColorDim). - PaddingLeft(1) - - TimeStyle = lipgloss.NewStyle(). - Foreground(ColorDim). - Width(20) - - RepoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ffffff")). - Bold(true). - Width(18) - - ActorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#d1d5db")). - Width(22) - - DetailStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#9ca3af")) - - StatusBarStyle = lipgloss.NewStyle(). - Foreground(ColorDim). - PaddingLeft(1) - - // Status panel styles - PanelBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#3b3b3b")). - Padding(0, 1) - - PanelTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")) - - PanelDividerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3b3b3b")) - - PanelRepoStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#3b82f6")) - - PanelDimStyle = lipgloss.NewStyle(). - Foreground(ColorDim) - - PanelCleanStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")) - - PanelDirtyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#eab308")) - - PanelWarnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#f97316")) - - PanelCIFailStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")) - - DividerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3b3b3b")) - - DetailRepoStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")) +import ( + "github.com/charmbracelet/lipgloss" + blit "github.com/blitui/blit" +) - DetailActorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#d1d5db")) +// 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 +} - DetailTimeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) -) +// 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), + } +} -// EventColor returns the color for a given event type. -func EventColor(eventType string) lipgloss.Color { +// EventColor returns the color for a given event type, derived from theme tokens. +func EventColor(eventType string, theme blit.Theme) lipgloss.Color { switch eventType { case "LocalPushEvent": - return ColorLocal + return theme.Color("local", theme.Accent) case "PushEvent": - return ColorPush + return theme.Color("create", theme.Positive) case "PullRequestEvent": - return ColorPR + return theme.Accent case "PullRequestReviewEvent", "PullRequestReviewCommentEvent": - return ColorReview + return theme.Color("review", theme.Cursor) case "IssueCommentEvent": - return ColorComment + return theme.Color("comment", theme.Muted) case "IssuesEvent": - return ColorIssue + return theme.Color("issue", theme.Warn) case "CreateEvent": - return ColorCreate + return theme.Color("create", theme.Positive) case "DeleteEvent": - return ColorDelete + return theme.Color("delete", theme.Negative) case "ReleaseEvent": - return ColorRelease + return theme.Color("release", theme.Flash) case "MemberEvent": - return ColorComment + return theme.Color("comment", theme.Muted) default: - return ColorDim + return theme.Muted } } -// LabelColor maps a display label (e.g. "PUSH", "PR") back to its color. -func LabelColor(label string) lipgloss.Color { +// LabelColor maps a display label back to its themed color. +func LabelColor(label string, theme blit.Theme) lipgloss.Color { switch label { case "LOCAL": - return ColorLocal + return theme.Color("local", theme.Accent) case "PUSH": - return ColorPush + return theme.Color("create", theme.Positive) case "PR": - return ColorPR + return theme.Accent case "REVIEW": - return ColorReview + return theme.Color("review", theme.Cursor) case "COMMENT": - return ColorComment + return theme.Color("comment", theme.Muted) case "ISSUE": - return ColorIssue + return theme.Color("issue", theme.Warn) case "CREATE": - return ColorCreate + return theme.Color("create", theme.Positive) case "DELETE": - return ColorDelete + return theme.Color("delete", theme.Negative) case "RELEASE": - return ColorRelease + return theme.Color("release", theme.Flash) case "STAR", "FORK": - return ColorComment + return theme.Color("comment", theme.Muted) default: - return ColorDim + return theme.Muted } } diff --git a/internal/ui/testdata/__snapshots__/empty_stream.snap b/internal/ui/testdata/__snapshots__/empty_stream.snap new file mode 100644 index 0000000..c9dcec4 --- /dev/null +++ b/internal/ui/testdata/__snapshots__/empty_stream.snap @@ -0,0 +1,20 @@ + Main │ Side + gitstream │ No local repos found + Watching: owner/repo-a, owner/repo-b │ + ○ repo-a ○ repo-b │ + │ +Time Repo Ty… Actor Detail │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + test test \ No newline at end of file diff --git a/internal/ui/testdata/__snapshots__/resized_stream.snap b/internal/ui/testdata/__snapshots__/resized_stream.snap new file mode 100644 index 0000000..c5fa497 --- /dev/null +++ b/internal/ui/testdata/__snapshots__/resized_stream.snap @@ -0,0 +1,15 @@ + gitstream + Watching: owner/repo-a, owner/repo-b + ○ repo-a ○ repo-b + +Time Repo Type Actor Detail + + + + + + + + + + test test \ No newline at end of file diff --git a/internal/ui/testdata/__snapshots__/tabs_empty.snap b/internal/ui/testdata/__snapshots__/tabs_empty.snap new file mode 100644 index 0000000..f3e0fe1 --- /dev/null +++ b/internal/ui/testdata/__snapshots__/tabs_empty.snap @@ -0,0 +1,20 @@ + Main │ Side + ◉ All │ ↑ Pushes │ ⎇ PRs │ No local repos found +━━━━━━━┴──────────┴─────── │ + gitstream │ + Watching: owner/repo-a, owner/repo-b │ + ○ repo-a ○ repo-b │ + │ +Time Repo Ty… Actor Detail │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + test test \ No newline at end of file diff --git a/internal/ui/testdata/__snapshots__/tabs_resized.snap b/internal/ui/testdata/__snapshots__/tabs_resized.snap new file mode 100644 index 0000000..20b9021 --- /dev/null +++ b/internal/ui/testdata/__snapshots__/tabs_resized.snap @@ -0,0 +1,15 @@ + ◉ All │ ↑ Pushes │ ⎇ PRs +━━━━━━━┴──────────┴─────── + gitstream + Watching: owner/repo-a, owner/repo-b + ○ repo-a ○ repo-b + +Time Repo Type Actor Detail + + + + + + + + test test \ No newline at end of file diff --git a/testdata/sessions/sort_toggle.tuisess b/testdata/sessions/sort_toggle.tuisess new file mode 100644 index 0000000..b382b82 --- /dev/null +++ b/testdata/sessions/sort_toggle.tuisess @@ -0,0 +1,23 @@ +{ + "version": 2, + "cols": 120, + "lines": 40, + "steps": [ + { + "kind": "key", + "key": "s" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │ No local repos found\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\nTime Repo Type Actor Detail │\n17:19:58 5m … repo-a PUSH alice pushed to main │\n17:21:58 3m … repo-b PR bob opened #42: Fix the bug │\n17:23:58 1m … repo-a ISSUE charlie opened #10: Something broken │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-10 17:23:58 │\n opened #10: Something broken ↵ open │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n test status left test status right" + }, + { + "kind": "key", + "key": "s" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │ No local repos found\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\nTime Repo Type Actor Detail │\n17:19:58 5m … repo-a PUSH alice pushed to main │\n17:21:58 3m … repo-b PR bob opened #42: Fix the bug │\n17:23:58 1m … repo-a ISSUE charlie opened #10: Something broken │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-10 17:23:58 │\n opened #10: Something broken ↵ open │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n test status left test status right" + } + ] +} \ No newline at end of file