diff --git a/README.md b/README.md index 34829c5..d1c0591 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ interval: 30 # polling interval in seconds ## Built With -- [tuikit-go](https://github.com/moneycaringcoder/tuikit-go) - TUI component toolkit +- [blit](https://github.com/blitui/blit) - TUI component toolkit - [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework - [Lip Gloss](https://github.com/charmbracelet/lipgloss) - Styling - GitHub REST API via `gh` CLI auth diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index f59d8ad..f82c14f 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -9,7 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/github" "github.com/moneycaringcoder/gitstream-tui/internal/ui" @@ -78,35 +78,56 @@ func main() { os.Exit(1) } - tuikit.CleanupOldBinary() + blit.CleanupOldBinary() + + // Build theme picker for runtime switching + presets := blit.Presets() + var pickerItems []blit.PickerItem + for name := range presets { + n := name + pickerItems = append(pickerItems, blit.PickerItem{ + Title: n, + Value: n, + }) + } + themePicker := blit.NewPicker(pickerItems, blit.PickerOpts{ + Placeholder: "Search themes...", + OnConfirm: func(item blit.PickerItem) { + name := item.Value.(string) + if _, ok := presets[name]; ok { + cfg.Theme = name + config.Save(cfg) + } + }, + }) debugLog := ui.NewDebugLog() stream := ui.NewEventStream(cfg, debugLog) panel := ui.NewStatusPanel() debugOverlay := ui.NewDebugOverlay(debugLog) - detailOverlay := tuikit.NewDetailOverlay(tuikit.DetailOverlayOpts[ui.DisplayEvent]{ + detailOverlay := blit.NewDetailOverlay(blit.DetailOverlayOpts[ui.DisplayEvent]{ Title: "Event Detail", - Render: func(de ui.DisplayEvent, w, h int, theme tuikit.Theme) string { - return renderEventDetail(de, w) + 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 { if key.String() == "o" { if url := de.Event.URL(); url != "" { - tuikit.OpenURL(url) + blit.OpenURL(url) } - return tuikit.Consumed() + return blit.Consumed() } return nil }, - KeyBindings: []tuikit.KeyBind{ + KeyBindings: []blit.KeyBind{ {Key: "o", Label: "Open in browser", Group: "DETAIL"}, }, }) stream.DetailOverlay = detailOverlay - // Config editor using tuikit.ConfigEditor - configEditor := tuikit.NewConfigEditor([]tuikit.ConfigField{ + // Config editor using blit.ConfigEditor + configEditor := blit.NewConfigEditor([]blit.ConfigField{ { Label: "Interval (sec)", Group: "Polling", @@ -168,13 +189,13 @@ func main() { }, }) - // Status bar: left = help hints, right = active filters - statusLeft := func() string { - return fmt.Sprintf(" ? help s sort t type c config D debug p pause r refresh 1-%d repo 0 clear", - len(cfg.Repos())) - } - - statusRight := func() string { + // Signal-driven status bar. Set() is called via goroutine to avoid + // 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") + rightSig := blit.NewSignal[string]("") + updateStatusRight := func() { var parts []string sortLabel := "oldest" if stream.IsNewestFirst() { @@ -188,13 +209,127 @@ func main() { ev := github.Event{Type: stream.TypeFilter()} parts = append(parts, "type:"+ev.Label()) } - return strings.Join(parts, " ") + " " + v := strings.Join(parts, " ") + " " + go rightSig.Set(v) } + updateStatusRight() - app := tuikit.NewApp( - tuikit.WithTheme(tuikit.DefaultTheme()), - tuikit.WithLayout(&tuikit.DualPane{ - Main: stream, + // Vim-style command bar + cmdBar := blit.NewCommandBar([]blit.Command{ + { + Name: "add", Args: true, Hint: "Add a repo (owner/repo)", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + if args == "" || !strings.Contains(args, "/") { + return nil + } + cfg.RepoEntries = append(cfg.RepoEntries, config.RepoEntry{Name: args}) + config.Save(cfg) + return nil + }, + }, + { + Name: "remove", Aliases: []string{"rm"}, Args: true, Hint: "Remove a repo", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries)) + for _, r := range cfg.RepoEntries { + if r.Name != args { + filtered = append(filtered, r) + } + } + cfg.RepoEntries = filtered + config.Save(cfg) + return nil + }, + }, + { + Name: "sort", Args: true, Hint: "Sort newest|oldest", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + if args == "newest" && !stream.IsNewestFirst() { + stream.ToggleSort() + updateStatusRight() + } else if args == "oldest" && stream.IsNewestFirst() { + stream.ToggleSort() + updateStatusRight() + } + return nil + }, + }, + { + Name: "filter", Args: true, Hint: "filter repo: or type:", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + if strings.HasPrefix(args, "repo:") { + stream.SetRepoFilter(strings.TrimPrefix(args, "repo:")) + updateStatusRight() + } else if strings.HasPrefix(args, "type:") { + stream.SetTypeFilter(strings.TrimPrefix(args, "type:")) + updateStatusRight() + } + return nil + }, + }, + { + Name: "clear", Hint: "Clear all filters", + Run: func(_ string) tea.Cmd { + stream.ClearFilters() + updateStatusRight() + return nil + }, + }, + { + Name: "theme", Args: true, Hint: "Set theme by name", + Run: func(args string) tea.Cmd { + args = strings.TrimSpace(args) + if t, ok := presets[args]; ok { + cfg.Theme = args + config.Save(cfg) + return blit.SetThemeCmd(t) + } + return nil + }, + }, + { + Name: "quit", Aliases: []string{"q"}, Hint: "Quit", + Run: func(_ string) tea.Cmd { return tea.Quit }, + }, + }) + + // Tab bar for event type filtering. + // Stream is assigned as Content only to the initial tab; OnChange moves + // it into the newly active slot so Tabs.SetFocused doesn't clobber + // the shared stream's focus state (last-writer-wins across 5 items). + tabItems := []blit.TabItem{ + {Title: "All", Glyph: "◉", Content: stream}, + {Title: "Pushes", Glyph: "↑"}, + {Title: "PRs", Glyph: "⎇"}, + {Title: "Issues", Glyph: "!"}, + {Title: "Local", Glyph: "⌂"}, + } + tabs := blit.NewTabs(tabItems, blit.TabsOpts{ + OnChange: func(idx int) { + filters := []string{"", "PushEvent", "PullRequestEvent", "IssuesEvent", "LocalPushEvent"} + if idx < len(filters) { + stream.SetTypeFilter(filters[idx]) + updateStatusRight() + } + // Move stream into the active tab, clear the rest. + for i := range tabItems { + if i == idx { + tabItems[i].Content = stream + } else { + tabItems[i].Content = nil + } + } + }, + }) + + app := blit.NewApp( + blit.WithTheme(resolveTheme(cfg.Theme)), + blit.WithLayout(&blit.DualPane{ + Main: tabs, Side: panel, MainName: "Stream", SideName: "Local", @@ -203,59 +338,43 @@ func main() { SideRight: true, ToggleKey: "", }), - tuikit.WithStatusBar(statusLeft, statusRight), - tuikit.WithHelp(), - tuikit.WithOverlay("Settings", "c", configEditor), - tuikit.WithOverlay("Debug", "D", debugOverlay), - tuikit.WithOverlay("Detail", "", detailOverlay), + 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), // Global keybindings - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "p", Label: "Pause/resume", Group: "CONTROLS", - Handler: func() { stream.TogglePause() }, + Handler: func() { stream.TogglePause(); updateStatusRight() }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "r", Label: "Refresh now", Group: "CONTROLS", Handler: func() { stream.ForceRefresh() }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "s", Label: "Toggle sort", Group: "CONTROLS", - Handler: func() { stream.ToggleSort() }, + Handler: func() { stream.ToggleSort(); updateStatusRight() }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "t", Label: "Type filter →", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(true) }, + Handler: func() { stream.CycleTypeFilter(true); updateStatusRight() }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "T", Label: "Type filter ←", Group: "FILTER", - Handler: func() { stream.CycleTypeFilter(false) }, + Handler: func() { stream.CycleTypeFilter(false); updateStatusRight() }, }), - tuikit.WithKeyBind(tuikit.KeyBind{ + blit.WithKeyBind(blit.KeyBind{ Key: "0", Label: "Clear filters", Group: "FILTER", - Handler: func() { stream.ClearFilters() }, + Handler: func() { stream.ClearFilters(); updateStatusRight() }, }), - tuikit.WithMouseSupport(), - tuikit.WithTickInterval(time.Second), - tuikit.WithAutoUpdate(updatewire.New(version)), + blit.WithMouseSupport(), + blit.WithTickInterval(time.Second), + blit.WithAutoUpdate(updatewire.New(version)), ) - // Register repo number filters (1-9) - for i := 1; i <= 9; i++ { - idx := i - 1 - app.AddKeyBind(tuikit.KeyBind{ - Key: fmt.Sprintf("%d", i), Label: fmt.Sprintf("Filter repo %d", i), Group: "FILTER", - Handler: func() { - repos := cfg.Repos() - if idx < len(repos) { - repo := repos[idx] - short := repo - if j := strings.LastIndex(repo, "/"); j >= 0 { - short = repo[j+1:] - } - stream.SetRepoFilter(short) - } - }, - }) - } if err := app.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -293,7 +412,7 @@ Config: ~/.config/gitstream/config.yaml `) } -func renderEventDetail(de ui.DisplayEvent, w int) string { +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")) @@ -356,18 +475,33 @@ func renderEventDetail(de ui.DisplayEvent, w int) string { lines = append(lines, "") lines = append(lines, labelStyle.Render("PR: ")+valStyle.Render(fmt.Sprintf("#%d %s", pr.Number, pr.Title))) lines = append(lines, labelStyle.Render("State: ")+valStyle.Render(pr.State)) + if pr.Body != "" { + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Description:")) + lines = append(lines, blit.Markdown(pr.Body, theme)) + } } // Issue info if issue := ev.Payload.Issue; issue != nil { lines = append(lines, "") lines = append(lines, labelStyle.Render("Issue: ")+valStyle.Render(fmt.Sprintf("#%d %s", issue.Number, issue.Title))) + if issue.Body != "" { + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Description:")) + lines = append(lines, blit.Markdown(issue.Body, theme)) + } } // Release info if rel := ev.Payload.Release; rel != nil { lines = append(lines, "") lines = append(lines, labelStyle.Render("Release: ")+valStyle.Render(rel.TagName+" — "+rel.Name)) + if rel.Body != "" { + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Release Notes:")) + lines = append(lines, blit.Markdown(rel.Body, theme)) + } } // Compare data (diff stats) @@ -383,3 +517,14 @@ func renderEventDetail(de ui.DisplayEvent, w int) string { return strings.Join(lines, "\n") } + +func resolveTheme(name string) blit.Theme { + if name == "" { + return blit.DefaultTheme() + } + presets := blit.Presets() + if t, ok := presets[name]; ok { + return t + } + return blit.DefaultTheme() +} diff --git a/go.mod b/go.mod index edf082b..9834aa7 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,63 @@ module github.com/moneycaringcoder/gitstream-tui go 1.26.1 require ( + github.com/blitui/blit v0.1.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/moneycaringcoder/tuikit-go v0.7.1 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect 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/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 + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/input v0.3.4 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.0 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/creack/pty v1.1.21 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabstv/go-bsdiff v1.0.5 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rcarmo/go-te v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.34.0 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index df34f81..eae00bf 100644 --- a/go.sum +++ b/go.sum @@ -1,57 +1,134 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +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/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= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= +github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= +github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= +github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw= +github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko= +github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= +github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= +github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= +github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 h1:eX+pdPPlD279OWgdx7f6KqIRSONuK7egk+jDx7OM3Ac= +github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76/go.mod h1:KjxHHirfLaw19iGT70HvVjHQsL1vq1SRQB4yOsAfy2s= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabstv/go-bsdiff v1.0.5 h1:g29MC/38Eaig+iAobW10/CiFvPtin8U3Jj4yNLcNG9k= +github.com/gabstv/go-bsdiff v1.0.5/go.mod h1:/Zz6GK+/f/TMylRtVaW3uwZlb0FZITILfA0q12XKGwg= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/moneycaringcoder/tuikit-go v0.7.1 h1:GMtOJjinc/radpd0mEXx6saKo5NyxOR0yvkQLyUiIfQ= -github.com/moneycaringcoder/tuikit-go v0.7.1/go.mod h1:2P2MPQGh/A+vpCcrgh5Taz+XqMlrpPgAsc2cd4W8ucg= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcarmo/go-te v0.1.0 h1:BH9Ub+A0AVBY5Q00El4QMVaWAMbycVHgMHQI2Kz8J/o= github.com/rcarmo/go-te v0.1.0/go.mod h1:cLsrtroxCubS+OHHwH0riB6xeNESfntaHEeI1jPAedk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/config/config.go b/internal/config/config.go index ea4ed45..62d7bf6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,7 @@ func (r RepoEntry) MarshalYAML() (interface{}, error) { type Config struct { RepoEntries []RepoEntry `yaml:"repos"` Interval int `yaml:"interval"` + Theme string `yaml:"theme,omitempty"` } // Repos returns just the repo name strings for backward compatibility. diff --git a/internal/github/events.go b/internal/github/events.go index 4667761..3dde8ff 100644 --- a/internal/github/events.go +++ b/internal/github/events.go @@ -51,11 +51,13 @@ type PullRequest struct { Number int `json:"number"` Title string `json:"title"` State string `json:"state"` + Body string `json:"body"` } type Issue struct { Number int `json:"number"` Title string `json:"title"` + Body string `json:"body"` } type Comment struct { @@ -65,6 +67,7 @@ type Comment struct { type Release struct { TagName string `json:"tag_name"` Name string `json:"name"` + Body string `json:"body"` } // CompareResult holds diff stats from the compare API. diff --git a/internal/ui/debug.go b/internal/ui/debug.go index e3c8e55..301e9e6 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -7,27 +7,37 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" ) -// DebugOverlay shows API stats and recent log entries. -// Implements tuikit.Overlay. +// DebugOverlay shows API stats and recent log entries using blit.LogViewer. +// Implements blit.Component and blit.Overlay. type DebugOverlay struct { - debugLog *DebugLog - active bool - width int - height int - focused bool + logViewer *blit.LogViewer + debugLog *DebugLog + active bool + width int + height int + focused bool } func NewDebugOverlay(debugLog *DebugLog) *DebugOverlay { - return &DebugOverlay{debugLog: debugLog} + lv := blit.NewLogViewer() + debugLog.SetLogViewer(lv) + return &DebugOverlay{ + logViewer: lv, + debugLog: debugLog, + } } func (d *DebugOverlay) Init() tea.Cmd { return nil } -func (d *DebugOverlay) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { - return d, nil +func (d *DebugOverlay) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea.Cmd) { + comp, cmd := d.logViewer.Update(msg, ctx) + if lv, ok := comp.(*blit.LogViewer); ok { + d.logViewer = lv + } + return d, cmd } func (d *DebugOverlay) View() string { @@ -38,71 +48,70 @@ func (d *DebugOverlay) View() string { b.WriteString(title + "\n\n") stats := d.debugLog.GetStats() - b.WriteString(logStatsStyle.Render(" API Stats") + "\n") - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Total calls: %d", stats.TotalCalls)) + "\n") - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Successful: %d", stats.SuccessCalls)) + "\n") + + statsHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#3b82f6")).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")) + + 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(logErrorStyle.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") + b.WriteString(errStyle.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") } else { - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") + b.WriteString(dim.Render(fmt.Sprintf(" Failed: %d", stats.FailedCalls)) + "\n") } - b.WriteString(logInfoStyle.Render(fmt.Sprintf(" Total events: %d", stats.TotalEvents)) + "\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(logInfoStyle.Render(fmt.Sprintf(" Last fetch: %s ago", ago)) + "\n") + b.WriteString(dim.Render(fmt.Sprintf(" Last fetch: %s ago", ago)) + "\n") } - b.WriteString("\n") - b.WriteString(logStatsStyle.Render(" Recent Log") + "\n") - - entries := d.debugLog.GetEntries() - maxShow := d.height - 14 - if maxShow < 5 { - maxShow = 5 - } - start := 0 - if len(entries) > maxShow { - start = len(entries) - maxShow - } - for i := len(entries) - 1; i >= start; i-- { - e := entries[i] - ts := logTimeStyle.Render(e.Time.Format("15:04:05")) - var levelStyle lipgloss.Style - var prefix string - switch e.Level { - case LogInfo: - levelStyle = logInfoStyle - prefix = "INFO" - case LogWarn: - levelStyle = logWarnStyle - prefix = "WARN" - case LogError: - levelStyle = logErrorStyle - prefix = "ERR " + // Repo health dots + if len(stats.RepoHealth) > 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(fmt.Sprintf(" %s %s", dot, dim.Render(repo)) + "\n") } - line := fmt.Sprintf(" %s %s %s", ts, levelStyle.Render(prefix), levelStyle.Render(e.Message)) - b.WriteString(line + "\n") } - if len(entries) == 0 { - b.WriteString(logInfoStyle.Render(" No log entries yet") + "\n") + // Rate limit + if stats.RateLimit > 0 { + b.WriteString(dim.Render(fmt.Sprintf(" Rate limit: %d/%d", stats.RateRemain, stats.RateLimit)) + "\n") } b.WriteString("\n") - b.WriteString(HelpStyle.PaddingLeft(2).Render("esc close | q quit") + "\n") + b.WriteString(d.logViewer.View()) return b.String() } -func (d *DebugOverlay) KeyBindings() []tuikit.KeyBind { - return []tuikit.KeyBind{ - {Key: "D", Label: "Debug log", Group: "OTHER"}, +func (d *DebugOverlay) KeyBindings() []blit.KeyBind { + return d.logViewer.KeyBindings() +} + +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 + if lvHeight < 4 { + lvHeight = 4 } + d.logViewer.SetSize(w, lvHeight) } -func (d *DebugOverlay) SetSize(w, h int) { d.width = w; d.height = h } -func (d *DebugOverlay) Focused() bool { return d.focused } -func (d *DebugOverlay) SetFocused(f bool) { d.focused = f } -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.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 d7cc0ec..bf0517e 100644 --- a/internal/ui/debuglog.go +++ b/internal/ui/debuglog.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/charmbracelet/lipgloss" + blit "github.com/blitui/blit" ) const maxLogEntries = 200 @@ -28,9 +28,10 @@ type LogEntry struct { // DebugLog is a thread-safe circular log buffer. type DebugLog struct { - mu sync.Mutex - entries []LogEntry - stats FetchStats + mu sync.Mutex + entries []LogEntry + stats FetchStats + logViewer *blit.LogViewer } // RepoHealth tracks per-repo fetch health. @@ -58,18 +59,45 @@ func NewDebugLog() *DebugLog { } } +// SetLogViewer wires a blit.LogViewer so that new log entries are also 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{}) { d.mu.Lock() defer d.mu.Unlock() + now := time.Now() msg := fmt.Sprintf(format, args...) d.entries = append(d.entries, LogEntry{ - Time: time.Now(), + 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), + Timestamp: now, + Message: msg, + }) + } } func (d *DebugLog) Info(format string, args ...interface{}) { d.Log(LogInfo, format, args...) } @@ -124,16 +152,3 @@ func (d *DebugLog) GetEntries() []LogEntry { return cp } -var ( - logInfoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6b7280")) - logWarnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#eab308")) - logErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ef4444")) - logTimeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#4b5563")) - logStatsStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3b82f6")). - Bold(true) -) diff --git a/internal/ui/panel.go b/internal/ui/panel.go index b6692d4..5905ef7 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -6,7 +6,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/gitstatus" ) @@ -16,18 +16,23 @@ type panelLine struct { } // StatusPanel displays local repo git status. -// Implements tuikit.Component. +// Implements blit.Component. type StatusPanel struct { repoStatus []gitstatus.RepoStatus - listView *tuikit.ListView[panelLine] + listView *blit.ListView[panelLine] focused bool width int + sections map[string]*blit.CollapsibleSection + headerMap map[int]string // line index → repo remote } func NewStatusPanel() *StatusPanel { - p := &StatusPanel{} - p.listView = tuikit.NewListView(tuikit.ListViewOpts[panelLine]{ - RenderItem: func(item panelLine, idx int, isCursor bool, theme tuikit.Theme) string { + p := &StatusPanel{ + sections: make(map[string]*blit.CollapsibleSection), + headerMap: make(map[int]string), + } + p.listView = blit.NewListView(blit.ListViewOpts[panelLine]{ + RenderItem: func(item panelLine, idx int, isCursor bool, theme blit.Theme) string { return item.text }, }) @@ -36,9 +41,19 @@ func NewStatusPanel() *StatusPanel { func (p *StatusPanel) Init() tea.Cmd { return nil } -func (p *StatusPanel) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { +func (p *StatusPanel) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + if msg.String() == "enter" || msg.String() == " " { + idx := p.listView.CursorIndex() + if remote, ok := p.headerMap[idx]; ok { + if sec, ok := p.sections[remote]; ok { + sec.Toggle() + p.rebuildContent() + return p, nil + } + } + } cmd := p.listView.HandleKey(msg) return p, cmd case gitStatusMsg: @@ -53,10 +68,11 @@ func (p *StatusPanel) View() string { return p.listView.View() } -func (p *StatusPanel) KeyBindings() []tuikit.KeyBind { - return []tuikit.KeyBind{ +func (p *StatusPanel) KeyBindings() []blit.KeyBind { + return []blit.KeyBind{ {Key: "up/k", Label: "Scroll up", Group: "NAVIGATION"}, {Key: "down/j", Label: "Scroll down", Group: "NAVIGATION"}, + {Key: "enter/space", Label: "Toggle section", Group: "NAVIGATION"}, } } @@ -71,8 +87,14 @@ func (p *StatusPanel) SetFocused(f bool) { p.listView.SetFocused(f) } +// SetTheme implements blit.Themed so the App's theme propagates to the ListView. +func (p *StatusPanel) SetTheme(t blit.Theme) { + p.listView.SetTheme(t) +} + func (p *StatusPanel) rebuildContent() { var lines []panelLine + p.headerMap = make(map[int]string) if len(p.repoStatus) == 0 { lines = append(lines, panelLine{text: ""}) @@ -89,61 +111,76 @@ func (p *StatusPanel) rebuildContent() { short = s.Remote[i+1:] } - if s.Error != nil { - lines = append(lines, panelLine{text: PanelRepoStyle.Render("◆ " + short)}) - lines = append(lines, panelLine{text: PanelDimStyle.Render(" error")}) - lines = append(lines, panelLine{text: ""}) - continue + // Get or create collapsible section for this repo. + sec, ok := p.sections[s.Remote] + if !ok { + sec = blit.NewCollapsibleSection(short) + // Default: collapse clean repos, expand dirty repos. + sec.Collapsed = (s.Uncommitted == 0 && s.Unpushed == 0 && s.Error == nil) + p.sections[s.Remote] = sec } - lines = append(lines, panelLine{text: PanelRepoStyle.Render("◆ " + short)}) - lines = append(lines, panelLine{text: PanelDimStyle.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) + // Header line with collapse indicator. + indicator := "▼" + if sec.Collapsed { + indicator = "▶" + } + p.headerMap[len(lines)] = s.Remote + lines = append(lines, panelLine{text: PanelRepoStyle.Render(indicator + " " + short)}) - if s.Uncommitted == 0 && s.Unpushed == 0 { - lines = append(lines, panelLine{text: PanelCleanStyle.Render(" ✓ clean")}) - } else { - if s.Uncommitted > 0 { - lines = append(lines, panelLine{text: PanelDirtyStyle.Render( - fmt.Sprintf(" ● %d uncommitted", s.Uncommitted))}) - } - if s.Unpushed > 0 { - lines = append(lines, panelLine{text: PanelWarnStyle.Render( - fmt.Sprintf(" ↑ %d unpushed", s.Unpushed))}) - for _, c := range s.UnpushedCommits { - msg := c.Message - maxLen := p.width - 10 - if maxLen < 10 { - maxLen = 10 + if !sec.Collapsed { + if s.Error != nil { + lines = append(lines, panelLine{text: PanelDimStyle.Render(" error")}) + } else { + lines = append(lines, panelLine{text: PanelDimStyle.Render(fmt.Sprintf(" ᛘ %s", s.Branch))}) + + if s.Uncommitted == 0 && s.Unpushed == 0 { + lines = append(lines, panelLine{text: PanelCleanStyle.Render(" ✓ clean")}) + } else { + if s.Uncommitted > 0 { + lines = append(lines, panelLine{text: PanelDirtyStyle.Render( + fmt.Sprintf(" ● %d uncommitted", s.Uncommitted))}) } - if len(msg) > maxLen { - msg = msg[:maxLen-1] + "…" + if s.Unpushed > 0 { + lines = append(lines, panelLine{text: PanelWarnStyle.Render( + fmt.Sprintf(" ↑ %d unpushed", s.Unpushed))}) + for _, c := range s.UnpushedCommits { + msg := c.Message + maxLen := p.width - 10 + if maxLen < 10 { + maxLen = 10 + } + if len(msg) > maxLen { + msg = msg[:maxLen-1] + "…" + } + lines = append(lines, panelLine{text: PanelDimStyle.Render( + fmt.Sprintf(" %s %s", c.SHA, msg))}) + } } - lines = append(lines, panelLine{text: PanelDimStyle.Render( - fmt.Sprintf(" %s %s", c.SHA, msg))}) } - } - } - if !s.HasUpstream { - lines = append(lines, panelLine{text: PanelDimStyle.Render(" ⚠ no upstream")}) - } + if !s.HasUpstream { + lines = append(lines, panelLine{text: PanelDimStyle.Render(" ⚠ no upstream")}) + } - if s.CI != nil { - var ciLine string - switch s.CI.Conclusion { - case "success": - ciLine = PanelCleanStyle.Render(" ✓ CI passed") - case "failure": - ciLine = PanelCIFailStyle.Render(" ✗ CI failed") - case "cancelled": - ciLine = PanelDimStyle.Render(" ○ CI cancelled") - default: - if s.CI.Status == "in_progress" { - ciLine = PanelWarnStyle.Render(" ◌ CI running") - } else { - ciLine = PanelDimStyle.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) + if s.CI != nil { + var ciLine string + switch s.CI.Conclusion { + case "success": + ciLine = PanelCleanStyle.Render(" ✓ CI passed") + case "failure": + ciLine = PanelCIFailStyle.Render(" ✗ CI failed") + case "cancelled": + ciLine = PanelDimStyle.Render(" ○ CI cancelled") + default: + if s.CI.Status == "in_progress" { + ciLine = PanelWarnStyle.Render(" ◌ CI running") + } else { + ciLine = PanelDimStyle.Render(fmt.Sprintf(" ○ CI %s", s.CI.Conclusion)) + } + } + lines = append(lines, panelLine{text: ciLine}) } } - lines = append(lines, panelLine{text: ciLine}) } lines = append(lines, panelLine{text: ""}) diff --git a/internal/ui/render.go b/internal/ui/render.go index bfb8a73..4dae6d0 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -4,8 +4,8 @@ import ( "fmt" "time" + blit "github.com/blitui/blit" "github.com/moneycaringcoder/gitstream-tui/internal/github" - tuikit "github.com/moneycaringcoder/tuikit-go" ) const flashDuration = 3 * time.Second @@ -16,9 +16,24 @@ type DisplayEvent struct { AddedAt time.Time } +// eventToRow converts a DisplayEvent into a blit.Row for the table. +func eventToRow(de DisplayEvent) blit.Row { + ev := de.Event + t := ev.CreatedAt.Local().Format("15:04:05") + rel := blit.RelativeTime(ev.CreatedAt, time.Now()) + return blit.Row{ + fmt.Sprintf("%s %s", t, rel), + ev.ShortRepo(), + ev.Label(), + ev.Actor.Login, + ev.Detail(), + } +} + +// 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 := tuikit.RelativeTime(ev.CreatedAt, now) + rel := blit.RelativeTime(ev.CreatedAt, now) timeStr := fmt.Sprintf("%s %s", t, rel) label := ev.Label() @@ -29,13 +44,13 @@ func renderEventLine(ev github.Event, now time.Time) string { detailRendered := DetailStyle.Render(detail) if url != "" { - detailRendered = tuikit.OSC8Link(url, detailRendered) + detailRendered = blit.OSC8Link(url, detailRendered) } line := fmt.Sprintf("%s %s %s %s %s", TimeStyle.Render(timeStr), RepoStyle.Render(repo), - LabelStyle(ev.Type).Render(label), + blit.Badge(label, EventColor(ev.Type), true), ActorStyle.Render(actor), detailRendered, ) diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go index 4795dfd..ea876df 100644 --- a/internal/ui/session_test.go +++ b/internal/ui/session_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/moneycaringcoder/tuikit-go/tuitest" + "github.com/blitui/blit/btest" ) // Session tests: record a scripted flow, save it to testdata/sessions, @@ -21,21 +21,21 @@ func sessionPath(name string) string { return filepath.Join(sessionsDir, name+".tuisess") } -func recordAndVerify(t *testing.T, name string, play func(r *tuitest.SessionRecorder)) { +func recordAndVerify(t *testing.T, name string, play func(r *btest.SessionRecorder)) { t.Helper() path := sessionPath(name) force := os.Getenv("GITSTREAM_UPDATE_SESSIONS") == "1" if _, err := os.Stat(path); force || os.IsNotExist(err) { tm, _ := testApp(t, testEvents()) - rec := tuitest.NewSessionRecorder(tm) + rec := btest.NewSessionRecorder(tm) play(rec) if err := rec.Save(path); err != nil { t.Fatalf("save %s: %v", name, err) } } - sess, err := tuitest.LoadSession(path) + sess, err := btest.LoadSession(path) if err != nil { t.Fatalf("load %s: %v", name, err) } @@ -50,7 +50,7 @@ func recordAndVerify(t *testing.T, name string, play func(r *tuitest.SessionReco // TestSession_RepoFeed covers the primary navigation flow: land on the // event stream, move the cursor down, and return to the top. func TestSession_RepoFeed(t *testing.T) { - recordAndVerify(t, "repo_feed", func(r *tuitest.SessionRecorder) { + recordAndVerify(t, "repo_feed", func(r *btest.SessionRecorder) { r.Key("down").Key("down").Key("up") }) } @@ -58,7 +58,7 @@ func TestSession_RepoFeed(t *testing.T) { // TestSession_TypeFilter covers the filter cycling flow: advance through // two type filters and clear back to the default. func TestSession_TypeFilter(t *testing.T) { - recordAndVerify(t, "type_filter", func(r *tuitest.SessionRecorder) { + recordAndVerify(t, "type_filter", func(r *btest.SessionRecorder) { r.Key("t").Key("t").Key("0") }) } diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 1c46570..5257076 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -8,7 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - tuikit "github.com/moneycaringcoder/tuikit-go" + 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" @@ -28,18 +28,19 @@ var typeFilters = []string{ "ReleaseEvent", } -// EventStream displays a scrollable list of GitHub events. -// Implements tuikit.Component. +// EventStream displays a scrollable table of GitHub events. +// Implements blit.Component. type EventStream struct { cfg *config.Config debugLog *DebugLog - allEvents []DisplayEvent - seen map[string]bool - seenLocalSHAs map[string]bool + allEvents []DisplayEvent + filteredEvents []DisplayEvent // parallel slice for row-click lookup + seen map[string]bool + seenLocalSHAs map[string]bool - listView *tuikit.ListView[DisplayEvent] - poller *tuikit.Poller + table *blit.Table + poller *blit.Poller filter string // repo name filter typeFilter string // event type filter @@ -51,36 +52,72 @@ type EventStream struct { focused bool width int - DetailOverlay *tuikit.DetailOverlay[DisplayEvent] + epmWindow []float64 // events-per-minute rolling window (last 30 points) + lastEPMTick time.Time + currentMinCount int + + DetailOverlay *blit.DetailOverlay[DisplayEvent] } func NewEventStream(cfg *config.Config, debugLog *DebugLog) *EventStream { s := &EventStream{ - cfg: cfg, - debugLog: debugLog, - seen: make(map[string]bool), - seenLocalSHAs: make(map[string]bool), - allEvents: make([]DisplayEvent, 0, 256), - knownRepos: append([]string{}, cfg.Repos()...), + cfg: cfg, + debugLog: debugLog, + seen: make(map[string]bool), + seenLocalSHAs: make(map[string]bool), + allEvents: make([]DisplayEvent, 0, 256), + filteredEvents: make([]DisplayEvent, 0, 256), + knownRepos: append([]string{}, cfg.Repos()...), + } + + columns := []blit.Column{ + {Title: "Time", Width: 2, MaxWidth: 20}, + {Title: "Repo", Width: 2, MaxWidth: 18}, + {Title: "Type", Width: 1, MaxWidth: 10}, + {Title: "Actor", Width: 2, MaxWidth: 22}, + {Title: "Detail", Width: 5}, } - s.listView = tuikit.NewListView(tuikit.ListViewOpts[DisplayEvent]{ - RenderItem: func(item DisplayEvent, idx int, isCursor bool, theme tuikit.Theme) string { - return renderEventLine(item.Event, time.Now()) + s.table = blit.NewTable(columns, nil, blit.TableOpts{ + Filterable: true, + CellRenderer: func(row blit.Row, colIdx int, isCursor bool, theme blit.Theme) string { + if colIdx >= len(row) { + return "" + } + switch colIdx { + case 0: // Time - dim gray + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render(row[colIdx]) + case 2: // Type - colored badge + return blit.Badge(row[colIdx], LabelColor(row[colIdx]), true) + default: + return row[colIdx] + } }, - HeaderFunc: func(theme tuikit.Theme) string { - return s.renderHeader() + RowStyler: func(row blit.Row, idx int, isCursor bool, theme blit.Theme) *lipgloss.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(lipgloss.Color("#1a2a1a")) + return &st + } + } + return nil }, - DetailFunc: func(item DisplayEvent, theme tuikit.Theme) string { - return s.renderDetailBar(item, theme) + OnRowClick: func(row blit.Row, rowIdx int) { + if rowIdx < len(s.filteredEvents) && s.DetailOverlay != nil { + s.DetailOverlay.Show(s.filteredEvents[rowIdx]) + } }, - FlashFunc: func(item DisplayEvent, now time.Time) bool { - return !item.AddedAt.IsZero() && now.Before(item.AddedAt.Add(flashDuration)) + DetailFunc: func(row blit.Row, rowIdx int, width int, theme blit.Theme) string { + if rowIdx < len(s.filteredEvents) { + return s.renderDetailBar(s.filteredEvents[rowIdx], theme) + } + return "" }, DetailHeight: 3, }) - s.poller = tuikit.NewPoller( + s.poller = blit.NewPoller( time.Duration(cfg.Interval)*time.Second, func() tea.Cmd { cmds := []tea.Cmd{pollEvents(cfg, debugLog, false)} @@ -101,32 +138,47 @@ func (s *EventStream) Init() tea.Cmd { ) } -func (s *EventStream) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { +func (s *EventStream) Update(msg tea.Msg, ctx blit.Context) (blit.Component, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: // Enter opens detail overlay instead of browser if msg.String() == "enter" && s.DetailOverlay != nil { - if item := s.listView.CursorItem(); item != nil { - s.DetailOverlay.Show(*item) - return s, tuikit.Consumed() + idx := s.table.CursorIndex() + if idx >= 0 && idx < len(s.filteredEvents) { + s.DetailOverlay.Show(s.filteredEvents[idx]) + return s, blit.Consumed() } } // 'o' opens in browser if msg.String() == "o" { - if item := s.listView.CursorItem(); item != nil { - if url := item.Event.URL(); url != "" { - tuikit.OpenURL(url) + idx := s.table.CursorIndex() + if idx >= 0 && idx < len(s.filteredEvents) { + if url := s.filteredEvents[idx].Event.URL(); url != "" { + blit.OpenURL(url) } - return s, tuikit.Consumed() + return s, blit.Consumed() } } - cmd := s.listView.HandleKey(msg) + // Let table handle its own keys (cursor, search, etc.) + comp, cmd := s.table.Update(msg, ctx) + _ = comp return s, cmd - case tuikit.TickMsg: - s.listView.Refresh() + case blit.TickMsg: s.poller.SetInterval(time.Duration(s.cfg.Interval) * time.Second) + // EPM window rotation + if s.lastEPMTick.IsZero() { + s.lastEPMTick = msg.Time + } else if msg.Time.Sub(s.lastEPMTick) >= time.Minute { + s.epmWindow = append(s.epmWindow, float64(s.currentMinCount)) + if len(s.epmWindow) > 30 { + s.epmWindow = s.epmWindow[1:] + } + s.currentMinCount = 0 + s.lastEPMTick = msg.Time + } + // Check if repos changed (config editor modified them) currentRepos := s.cfg.Repos() if !slicesEqual(currentRepos, s.knownRepos) { @@ -155,7 +207,7 @@ func (s *EventStream) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { return s, nil } -func (s *EventStream) handleEvents(msg eventsMsg) (tuikit.Component, tea.Cmd) { +func (s *EventStream) handleEvents(msg eventsMsg) (blit.Component, tea.Cmd) { for _, e := range msg.errors { s.debugLog.Error("%s", e) } @@ -176,6 +228,7 @@ func (s *EventStream) handleEvents(msg eventsMsg) (tuikit.Component, tea.Cmd) { s.allEvents = append(s.allEvents, DisplayEvent{Event: ev, AddedAt: addedAt}) newCount++ } + s.currentMinCount += newCount if newCount > 0 { sort.Slice(s.allEvents, func(i, j int) bool { return s.allEvents[i].Event.CreatedAt.Before(s.allEvents[j].Event.CreatedAt) @@ -184,16 +237,33 @@ func (s *EventStream) handleEvents(msg eventsMsg) (tuikit.Component, tea.Cmd) { s.rebuildFiltered() if atEdge { if s.newestFirst { - s.listView.ScrollToTop() + s.table.SetCursor(0) } else { - s.listView.ScrollToBottom() + s.table.SetCursor(len(s.filteredEvents) - 1) } } } - return s, nil + + 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)) + } + + 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)) + } + } + + return s, tea.Batch(cmds...) } -func (s *EventStream) handleGitStatus(msg gitStatusMsg) (tuikit.Component, tea.Cmd) { +func (s *EventStream) handleGitStatus(msg gitStatusMsg) (blit.Component, tea.Cmd) { now := time.Now() newLocal := 0 for _, st := range msg.statuses { @@ -234,9 +304,9 @@ func (s *EventStream) handleGitStatus(msg gitStatusMsg) (tuikit.Component, tea.C s.rebuildFiltered() if atEdge { if s.newestFirst { - s.listView.ScrollToTop() + s.table.SetCursor(0) } else { - s.listView.ScrollToBottom() + s.table.SetCursor(len(s.filteredEvents) - 1) } } } @@ -244,7 +314,8 @@ func (s *EventStream) handleGitStatus(msg gitStatusMsg) (tuikit.Component, tea.C } func (s *EventStream) View() string { - return s.listView.View() + header := s.renderHeader() + return header + "\n\n" + s.table.View() } func (s *EventStream) renderHeader() string { @@ -288,14 +359,18 @@ func (s *EventStream) renderHeader() string { statusParts = append(statusParts, lipgloss.NewStyle().Foreground(rateColor).Render( fmt.Sprintf("API %d/%d", stats.RateRemain, stats.RateLimit))) } + if len(s.epmWindow) >= 2 { + spark, _ := blit.Sparkline(s.epmWindow, 30, nil) + statusParts = append(statusParts, "Activity: "+spark) + } status := SubtitleStyle.Render(strings.Join(statusParts, " ")) - return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status, "") + return lipgloss.JoinVertical(lipgloss.Left, title, repoList, status) } -func (s *EventStream) renderDetailBar(de DisplayEvent, theme tuikit.Theme) string { +func (s *EventStream) renderDetailBar(de DisplayEvent, theme blit.Theme) string { ev := de.Event - divider := tuikit.Divider(s.width, theme) + divider := blit.Divider(s.width, theme) repo := ev.Repo.Name label := ev.Label() @@ -304,13 +379,13 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme tuikit.Theme) strin color := EventColor(ev.Type) line1 := fmt.Sprintf(" %s %s %s %s", - DetailLabelStyle(color).Render(label), + blit.Badge(label, color, true), DetailRepoStyle.Render(repo), DetailActorStyle.Render(actor), DetailTimeStyle.Render(t), ) - detail := tuikit.Truncate(ev.Detail(), s.width-20) + detail := blit.Truncate(ev.Detail(), s.width-20) urlHint := "" if url := ev.URL(); url != "" { urlHint = DetailTimeStyle.Render(" ↵ open") @@ -320,24 +395,31 @@ func (s *EventStream) renderDetailBar(de DisplayEvent, theme tuikit.Theme) strin return divider + "\n" + line1 + "\n" + line2 } -func (s *EventStream) KeyBindings() []tuikit.KeyBind { - bindings := s.listView.KeyBindings() +func (s *EventStream) KeyBindings() []blit.KeyBind { + bindings := s.table.KeyBindings() bindings = append(bindings, - tuikit.KeyBind{Key: "enter", Label: "Event detail", Group: "NAVIGATION"}, - tuikit.KeyBind{Key: "o", Label: "Open in browser", Group: "NAVIGATION"}, + blit.KeyBind{Key: "enter", Label: "Event detail", Group: "NAVIGATION"}, + blit.KeyBind{Key: "o", Label: "Open in browser", Group: "NAVIGATION"}, ) return bindings } func (s *EventStream) SetSize(w, h int) { s.width = w - s.listView.SetSize(w, h) + headerHeight := 4 + s.table.SetSize(w, h-headerHeight) } func (s *EventStream) Focused() bool { return s.focused } func (s *EventStream) SetFocused(f bool) { s.focused = f - s.listView.SetFocused(f) + s.table.SetFocused(f) +} + +// SetTheme implements blit.Themed so the App's theme propagates through +// Tabs → EventStream → Table. +func (s *EventStream) SetTheme(t blit.Theme) { + s.table.SetTheme(t) } // Public methods for app-level keybinding handlers. @@ -351,6 +433,11 @@ func (s *EventStream) SetRepoFilter(repo string) { s.rebuildFiltered() } +func (s *EventStream) SetTypeFilter(t string) { + s.typeFilter = t + s.rebuildFiltered() +} + func (s *EventStream) ClearFilters() { s.filter = "" s.typeFilter = "" @@ -377,9 +464,9 @@ func (s *EventStream) ToggleSort() { s.newestFirst = !s.newestFirst s.rebuildFiltered() if s.newestFirst { - s.listView.ScrollToTop() + s.table.SetCursor(0) } else { - s.listView.ScrollToBottom() + s.table.SetCursor(len(s.filteredEvents) - 1) } } @@ -405,28 +492,34 @@ func (s *EventStream) skipEvent(de DisplayEvent) bool { } func (s *EventStream) rebuildFiltered() { - var filtered []DisplayEvent + s.filteredEvents = s.filteredEvents[:0] if s.newestFirst { for i := len(s.allEvents) - 1; i >= 0; i-- { if !s.skipEvent(s.allEvents[i]) { - filtered = append(filtered, s.allEvents[i]) + s.filteredEvents = append(s.filteredEvents, s.allEvents[i]) } } } else { for _, de := range s.allEvents { if !s.skipEvent(de) { - filtered = append(filtered, de) + s.filteredEvents = append(s.filteredEvents, de) } } } - s.listView.SetItems(filtered) + rows := make([]blit.Row, len(s.filteredEvents)) + for i, de := range s.filteredEvents { + rows[i] = eventToRow(de) + } + s.table.SetRows(rows) } func (s *EventStream) isAtNewEdge() bool { + total := len(s.filteredEvents) + idx := s.table.CursorIndex() if s.newestFirst { - return s.listView.IsAtTop() + return idx == 0 } - return s.listView.IsAtBottom() + return idx >= total-1 } func slicesEqual(a, b []string) bool { diff --git a/internal/ui/stream_test.go b/internal/ui/stream_test.go index 4164080..6107407 100644 --- a/internal/ui/stream_test.go +++ b/internal/ui/stream_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" - tuikit "github.com/moneycaringcoder/tuikit-go" - "github.com/moneycaringcoder/tuikit-go/tuitest" + blit "github.com/blitui/blit" + "github.com/blitui/blit/btest" "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/github" ) @@ -52,29 +52,29 @@ func testEvents() []github.Event { } } -// testApp builds a tuikit.App wrapping an EventStream with injected events. -func testApp(t testing.TB, events []github.Event) (*tuitest.TestModel, *EventStream) { +// testApp builds a blit.App wrapping an EventStream with injected events. +func testApp(t testing.TB, events []github.Event) (*btest.TestModel, *EventStream) { t.Helper() cfg := testConfig() debugLog := NewDebugLog() stream := NewEventStream(cfg, debugLog) panel := NewStatusPanel() - app := tuikit.NewApp( - tuikit.WithLayout(&tuikit.DualPane{ + app := blit.NewApp( + blit.WithLayout(&blit.DualPane{ Main: stream, Side: panel, SideWidth: 32, MinMainWidth: 40, SideRight: true, }), - tuikit.WithStatusBar( + blit.WithStatusBar( func() string { return " test status left" }, func() string { return "test status right " }, ), ) - tm := tuitest.NewTestModel(t, app.Model(), 120, 40) + tm := btest.NewTestModel(t, app.Model(), 120, 40) // Inject events directly if len(events) > 0 { @@ -88,20 +88,20 @@ func TestStreamRendersEvents(t *testing.T) { tm, _ := testApp(t, testEvents()) s := tm.Screen() - tuitest.AssertContains(t, s, "alice") - tuitest.AssertContains(t, s, "bob") - tuitest.AssertContains(t, s, "charlie") - tuitest.AssertContains(t, s, "repo-a") - tuitest.AssertContains(t, s, "repo-b") + btest.AssertContains(t, s, "alice") + btest.AssertContains(t, s, "bob") + btest.AssertContains(t, s, "charlie") + btest.AssertContains(t, s, "repo-a") + btest.AssertContains(t, s, "repo-b") } func TestStreamRendersHeader(t *testing.T) { tm, _ := testApp(t, testEvents()) s := tm.Screen() - tuitest.AssertContains(t, s, "gitstream") - tuitest.AssertContains(t, s, "owner/repo-a") - tuitest.AssertContains(t, s, "owner/repo-b") + btest.AssertContains(t, s, "gitstream") + btest.AssertContains(t, s, "owner/repo-a") + btest.AssertContains(t, s, "owner/repo-b") } func TestStreamCursorNavigation(t *testing.T) { @@ -113,8 +113,8 @@ func TestStreamCursorNavigation(t *testing.T) { s := tm.Screen() // Should still render all events - tuitest.AssertContains(t, s, "alice") - tuitest.AssertContains(t, s, "charlie") + btest.AssertContains(t, s, "alice") + btest.AssertContains(t, s, "charlie") } func TestStreamSortToggle(t *testing.T) { @@ -133,7 +133,7 @@ func TestStreamSortToggle(t *testing.T) { t.Error("should be newest first after toggle") } // Events should still render - tuitest.AssertContains(t, s, "alice") + btest.AssertContains(t, s, "alice") } func TestStreamTypeFilter(t *testing.T) { @@ -145,7 +145,7 @@ func TestStreamTypeFilter(t *testing.T) { stream.CycleTypeFilter(true) // -> PushEvent s := tm.Screen() - tuitest.AssertContains(t, s, "alice") // alice has PushEvent + btest.AssertContains(t, s, "alice") // alice has PushEvent // bob's PR event should be filtered out if s.Contains("bob") { t.Error("bob's PullRequestEvent should be filtered when type=PushEvent") @@ -158,7 +158,7 @@ func TestStreamRepoFilter(t *testing.T) { stream.SetRepoFilter("repo-b") s := tm.Screen() - tuitest.AssertContains(t, s, "bob") // bob is in repo-b + btest.AssertContains(t, s, "bob") // bob is in repo-b // alice and charlie are in repo-a, should be hidden if s.Contains("alice") { t.Error("alice should be filtered out when repo=repo-b") @@ -201,8 +201,8 @@ func TestStreamEmptyState(t *testing.T) { s := tm.Screen() // Should still render header - tuitest.AssertContains(t, s, "gitstream") - tuitest.AssertNotEmpty(t, s) + btest.AssertContains(t, s, "gitstream") + btest.AssertNotEmpty(t, s) } func TestStreamResize(t *testing.T) { @@ -211,12 +211,12 @@ func TestStreamResize(t *testing.T) { // Resize to small tm.SendResize(60, 20) s := tm.Screen() - tuitest.AssertNotEmpty(t, s) - tuitest.AssertContains(t, s, "gitstream") + btest.AssertNotEmpty(t, s) + btest.AssertContains(t, s, "gitstream") // Resize to large tm.SendResize(200, 50) s = tm.Screen() - tuitest.AssertNotEmpty(t, s) - tuitest.AssertContains(t, s, "alice") + btest.AssertNotEmpty(t, s) + btest.AssertContains(t, s, "alice") } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index b130a30..b1eacd2 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -45,9 +45,6 @@ var ( Foreground(ColorDim). PaddingLeft(1) - HelpStyle = lipgloss.NewStyle(). - Foreground(ColorDim) - // Status panel styles PanelBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). @@ -122,15 +119,30 @@ func EventColor(eventType string) lipgloss.Color { } } -// LabelStyle returns a styled label for a given event type. -func LabelStyle(eventType string) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(EventColor(eventType)). - Width(9). - Bold(true) -} - -// DetailLabelStyle returns a bold label style with a given color. -func DetailLabelStyle(color lipgloss.Color) lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(color) +// LabelColor maps a display label (e.g. "PUSH", "PR") back to its color. +func LabelColor(label string) lipgloss.Color { + switch label { + case "LOCAL": + return ColorLocal + case "PUSH": + return ColorPush + case "PR": + return ColorPR + case "REVIEW": + return ColorReview + case "COMMENT": + return ColorComment + case "ISSUE": + return ColorIssue + case "CREATE": + return ColorCreate + case "DELETE": + return ColorDelete + case "RELEASE": + return ColorRelease + case "STAR", "FORK": + return ColorComment + default: + return ColorDim + } } diff --git a/internal/updatewire/updatewire.go b/internal/updatewire/updatewire.go index 85cb14c..400e437 100644 --- a/internal/updatewire/updatewire.go +++ b/internal/updatewire/updatewire.go @@ -1,30 +1,30 @@ -// Package updatewire builds the tuikit UpdateConfig used by gitstream. +// Package updatewire builds the blit UpdateConfig used by gitstream. // Extracting this into a tiny helper makes the update pipeline testable // against updatetest.NewMockServer without spinning up the full TUI. package updatewire import ( - tuikit "github.com/moneycaringcoder/tuikit-go" + blit "github.com/blitui/blit" ) // New returns a UpdateConfig wired for the gitstream binary. Callers pass // the current version string (typically set via ldflags). The mode is // UpdateForced so release notes carrying a `minimum_version:` marker // trigger the full-screen update gate automatically. -func New(version string) tuikit.UpdateConfig { - return tuikit.UpdateConfig{ +func New(version string) blit.UpdateConfig { + return blit.UpdateConfig{ Owner: "moneycaringcoder", Repo: "gitstream-tui", BinaryName: "gitstream", Version: version, - Mode: tuikit.UpdateForced, + Mode: blit.UpdateForced, } } // NewWithBaseURL is the test hook: it returns the same config as New but // pointed at a mock server URL so update_test.go can assert the full // CheckForUpdate flow without hitting api.github.com. -func NewWithBaseURL(version, baseURL, cacheDir string) tuikit.UpdateConfig { +func NewWithBaseURL(version, baseURL, cacheDir string) blit.UpdateConfig { cfg := New(version) cfg.APIBaseURL = baseURL cfg.CacheDir = cacheDir diff --git a/internal/updatewire/updatewire_test.go b/internal/updatewire/updatewire_test.go index 5931ae2..dc413cd 100644 --- a/internal/updatewire/updatewire_test.go +++ b/internal/updatewire/updatewire_test.go @@ -3,15 +3,15 @@ package updatewire import ( "testing" - tuikit "github.com/moneycaringcoder/tuikit-go" - "github.com/moneycaringcoder/tuikit-go/updatetest" + blit "github.com/blitui/blit" + "github.com/blitui/blit/updatetest" ) // TestNew_DefaultsAreForced asserts the consumer wiring uses UpdateForced // so a minimum_version marker in release notes promotes the update gate. func TestNew_DefaultsAreForced(t *testing.T) { cfg := New("v0.0.0") - if cfg.Mode != tuikit.UpdateForced { + if cfg.Mode != blit.UpdateForced { t.Errorf("mode = %v, want UpdateForced", cfg.Mode) } if cfg.BinaryName != "gitstream" { @@ -35,7 +35,7 @@ func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { defer srv.Close() cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) - res, err := tuikit.CheckForUpdate(cfg) + res, err := blit.CheckForUpdate(cfg) if err != nil { t.Fatalf("CheckForUpdate: %v", err) } @@ -58,7 +58,7 @@ func TestCheckForUpdate_CurrentVersionNoGate(t *testing.T) { defer srv.Close() cfg := NewWithBaseURL("v1.0.0", srv.URL, t.TempDir()) - res, err := tuikit.CheckForUpdate(cfg) + res, err := blit.CheckForUpdate(cfg) if err != nil { t.Fatalf("CheckForUpdate: %v", err) } @@ -83,10 +83,10 @@ func TestCheckForUpdate_SkippedVersionNoGate(t *testing.T) { defer srv.Close() cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) - if err := tuikit.SkipVersion(cfg, "v1.0.0"); err != nil { + if err := blit.SkipVersion(cfg, "v1.0.0"); err != nil { t.Fatalf("SkipVersion: %v", err) } - res, err := tuikit.CheckForUpdate(cfg) + res, err := blit.CheckForUpdate(cfg) if err != nil { t.Fatalf("CheckForUpdate: %v", err) } @@ -107,7 +107,7 @@ func TestCheckForUpdate_DisabledShortCircuits(t *testing.T) { cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) cfg.Disabled = true - res, err := tuikit.CheckForUpdate(cfg) + res, err := blit.CheckForUpdate(cfg) if err != nil { t.Fatalf("CheckForUpdate: %v", err) }