Skip to content
This repository was archived by the owner on Apr 13, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 23 additions & 89 deletions cmd/gitstream/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,9 @@ package main
import (
"fmt"
"os"
"strconv"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
blit "github.com/blitui/blit"
"github.com/moneycaringcoder/gitstream-tui/internal/config"
"github.com/moneycaringcoder/gitstream-tui/internal/github"
Expand Down Expand Up @@ -111,7 +108,7 @@ func main() {
Render: func(de ui.DisplayEvent, w, h int, theme blit.Theme) string {
return renderEventDetail(de, w, theme)
},
OnKey: func(de ui.DisplayEvent, key tea.KeyMsg) tea.Cmd {
OnKey: func(de ui.DisplayEvent, key blit.KeyMsg) blit.Cmd {
if key.String() == "o" {
if url := de.Event.URL(); url != "" {
blit.OpenURL(url)
Expand All @@ -126,76 +123,8 @@ func main() {
})
stream.DetailOverlay = detailOverlay

// Config editor using blit.ConfigEditor
configEditor := blit.NewConfigEditor([]blit.ConfigField{
{
Label: "Interval (sec)",
Group: "Polling",
Hint: "How often to poll GitHub for new events (min 5)",
Get: func() string { return strconv.Itoa(cfg.Interval) },
Validate: func(v string) error {
n, err := strconv.Atoi(v)
if err != nil || n < 5 {
return fmt.Errorf("must be a number >= 5")
}
return nil
},
Set: func(v string) error {
n, _ := strconv.Atoi(v)
cfg.Interval = n
return config.Save(cfg)
},
},
{
Label: "Add repo",
Group: "Repos",
Hint: "Add a new repo to watch (owner/repo format)",
Get: func() string { return "" },
Validate: func(v string) error {
v = strings.TrimSpace(v)
if v == "" || !strings.Contains(v, "/") {
return fmt.Errorf("must be owner/repo format")
}
for _, r := range cfg.RepoEntries {
if r.Name == v {
return fmt.Errorf("repo already exists")
}
}
return nil
},
Set: func(v string) error {
v = strings.TrimSpace(v)
cfg.RepoEntries = append(cfg.RepoEntries, config.RepoEntry{Name: v})
return config.Save(cfg)
},
},
{
Label: "Remove repo",
Group: "Repos",
Hint: "Remove a watched repo (owner/repo format)",
Get: func() string { return "" },
Validate: func(v string) error {
v = strings.TrimSpace(v)
for _, r := range cfg.RepoEntries {
if r.Name == v {
return nil
}
}
return fmt.Errorf("repo not found")
},
Set: func(v string) error {
v = strings.TrimSpace(v)
filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries))
for _, r := range cfg.RepoEntries {
if r.Name != v {
filtered = append(filtered, r)
}
}
cfg.RepoEntries = filtered
return config.Save(cfg)
},
},
})
// Config editor auto-generated from blit struct tags
configEditor := config.Editor()

// Signal-driven status bar. Set() is called via goroutine to avoid
// deadlocking — bubbletea's p.msgs is unbuffered, and Signal.Set triggers
Expand Down Expand Up @@ -226,7 +155,7 @@ func main() {
cmdBar := blit.NewCommandBar([]blit.Command{
{
Name: "add", Args: true, Hint: "Add a repo (owner/repo)",
Run: func(args string) tea.Cmd {
Run: func(args string) blit.Cmd {
args = strings.TrimSpace(args)
if args == "" || !strings.Contains(args, "/") {
return nil
Expand All @@ -238,7 +167,7 @@ func main() {
},
{
Name: "remove", Aliases: []string{"rm"}, Args: true, Hint: "Remove a repo",
Run: func(args string) tea.Cmd {
Run: func(args string) blit.Cmd {
args = strings.TrimSpace(args)
filtered := make([]config.RepoEntry, 0, len(cfg.RepoEntries))
for _, r := range cfg.RepoEntries {
Expand All @@ -253,7 +182,7 @@ func main() {
},
{
Name: "sort", Args: true, Hint: "Sort newest|oldest",
Run: func(args string) tea.Cmd {
Run: func(args string) blit.Cmd {
args = strings.TrimSpace(args)
if args == "newest" && !stream.IsNewestFirst() {
stream.ToggleSort()
Expand All @@ -267,7 +196,7 @@ func main() {
},
{
Name: "filter", Args: true, Hint: "filter repo:<name> or type:<type>",
Run: func(args string) tea.Cmd {
Run: func(args string) blit.Cmd {
args = strings.TrimSpace(args)
if strings.HasPrefix(args, "repo:") {
stream.SetRepoFilter(strings.TrimPrefix(args, "repo:"))
Expand All @@ -281,15 +210,15 @@ func main() {
},
{
Name: "clear", Hint: "Clear all filters",
Run: func(_ string) tea.Cmd {
Run: func(_ string) blit.Cmd {
stream.ClearFilters()
updateStatusRight()
return nil
},
},
{
Name: "theme", Args: true, Hint: "Set theme by name",
Run: func(args string) tea.Cmd {
Run: func(args string) blit.Cmd {
args = strings.TrimSpace(args)
if t, ok := presets[args]; ok {
cfg.Theme = args
Expand All @@ -301,7 +230,7 @@ func main() {
},
{
Name: "quit", Aliases: []string{"q"}, Hint: "Quit",
Run: func(_ string) tea.Cmd { return tea.Quit },
Run: func(_ string) blit.Cmd { return blit.Quit },
},
})

Expand Down Expand Up @@ -427,10 +356,15 @@ func main() {
blit.WithAnimations(true),
)

if err := app.Run(); err != nil {
action, err := app.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if action == blit.UpdateActionRestart {
fmt.Fprintln(os.Stderr, "Update installed. Please restart gitstream.")
os.Exit(0)
}
}

func printHelp() {
Expand Down Expand Up @@ -472,10 +406,10 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string {
bc.SetSize(w, 1)
bc.SetTheme(theme)

labelStyle := lipgloss.NewStyle().Foreground(theme.Muted)
valStyle := lipgloss.NewStyle().Foreground(theme.Text)
labelStyle := blit.NewStyle().Foreground(theme.Muted)
valStyle := blit.NewStyle().Foreground(theme.Text)
color := ui.EventColor(ev.Type, theme)
typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true)
typeStyle := blit.NewStyle().Foreground(color).Bold(true)

lines := []string{
bc.View(),
Expand Down Expand Up @@ -512,7 +446,7 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string {
if len(ev.Payload.Commits) > 0 {
lines = append(lines, "")
lines = append(lines, labelStyle.Render("Commits:"))
shaStyle := lipgloss.NewStyle().Foreground(theme.Warn)
shaStyle := blit.NewStyle().Foreground(theme.Warn)
for _, c := range ev.Payload.Commits {
sha := c.SHA
if len(sha) > 7 {
Expand Down Expand Up @@ -568,8 +502,8 @@ func renderEventDetail(de ui.DisplayEvent, w int, theme blit.Theme) string {
if cd := ev.CompareData; cd != nil {
lines = append(lines, "")
lines = append(lines, labelStyle.Render(fmt.Sprintf("Files changed: %d, Commits: %d", len(cd.Files), cd.TotalCommits)))
addStyle := lipgloss.NewStyle().Foreground(theme.Positive)
delStyle := lipgloss.NewStyle().Foreground(theme.Negative)
addStyle := blit.NewStyle().Foreground(theme.Positive)
delStyle := blit.NewStyle().Foreground(theme.Negative)
for _, f := range cd.Files {
adds := addStyle.Render(fmt.Sprintf("+%d", f.Additions))
dels := delStyle.Render(fmt.Sprintf("-%d", f.Deletions))
Expand All @@ -588,7 +522,7 @@ func resolveTheme(name string) blit.Theme {
}
}
t := blit.DefaultTheme()
t.Extra = map[string]lipgloss.Color{
t.Extra = map[string]blit.Color{
"info": "#06b6d4",
"create": "#22c55e",
"delete": "#ef4444",
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ module github.com/moneycaringcoder/gitstream-tui
go 1.26.1

require (
github.com/blitui/blit v0.1.2
github.com/blitui/blit v0.2.24
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.6
)

Expand All @@ -19,6 +18,7 @@ require (
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/glamour v1.0.0 // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/log v0.4.1 // indirect
github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 // indirect
github.com/charmbracelet/wish v1.4.7 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/blitui/blit v0.1.2 h1:2TZ836XZ9i8EOA9S8jrTtXvA9O7PqHwCih65fxA5LRY=
github.com/blitui/blit v0.1.2/go.mod h1:OQ3XhjGhDneebNJs/ldXqRRXKG1H3+XrYWefdHDD+LY=
github.com/blitui/blit v0.2.24 h1:42A20iL8O8ADIPrIgZDlSFv9gUAC79FARfpfVPBdm+w=
github.com/blitui/blit v0.2.24/go.mod h1:OQ3XhjGhDneebNJs/ldXqRRXKG1H3+XrYWefdHDD+LY=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
Expand Down
80 changes: 50 additions & 30 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,11 @@ type RepoEntry struct {
// - name: owner/repo
// path: /home/user/projects/repo
func (r *RepoEntry) UnmarshalYAML(unmarshal func(interface{}) error) error {
// Try plain string first
var s string
if err := unmarshal(&s); err == nil {
r.Name = s
return nil
}
// Otherwise struct
type raw RepoEntry
return unmarshal((*raw)(r))
}
Expand All @@ -40,10 +38,12 @@ func (r RepoEntry) MarshalYAML() (interface{}, error) {
return (raw)(r), nil
}

// Config holds the application configuration.
// blit struct tags enable auto-generated ConfigEditor and CLI commands.
type Config struct {
RepoEntries []RepoEntry `yaml:"repos"`
Interval int `yaml:"interval"`
Theme string `yaml:"theme,omitempty"`
RepoEntries []RepoEntry `yaml:"repos" blit:"label=Repos,group=Config,hint=Watched repos (owner/repo format)"`
Interval int `yaml:"interval" blit:"label=Interval (sec),group=Polling,hint=Poll frequency (min 5),default=30,min=5"`
Theme string `yaml:"theme,omitempty" blit:"label=Theme,group=Appearance,hint=Theme name (use ctrl+t to pick),readonly=true"`
}

// Repos returns just the repo name strings for backward compatibility.
Expand All @@ -66,40 +66,56 @@ func (c *Config) ExplicitPaths() map[string]string {
return m
}

func DefaultPath() string {
p, err := blit.DefaultConfigPath("gitstream")
if err != nil {
home, _ := os.UserHomeDir()
return home + "/.config/gitstream/config.yaml"
}
return p
}
// blitCfg is the blit.Config wrapper. Initialized on first Load.
var blitCfg *blit.Config[Config]

// Load loads the config using blit.Config[T] with struct tag defaults.
func Load() (*Config, error) {
path := DefaultPath()

var cfg Config
if err := blit.LoadYAML(path, &cfg); err != nil {
return nil, err
}

// LoadYAML returns nil for missing files, so check if we got anything.
if cfg.RepoEntries == nil {
// Check if the file actually exists to give a helpful message.
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("no config found at %s - run 'gitstream add owner/repo' to get started", path)
var err error
if blitCfg == nil {
blitCfg, err = blit.LoadConfig[Config]("gitstream")
if err != nil {
// Check if the file doesn't exist to give a helpful message.
path, pathErr := blit.DefaultConfigPath("gitstream")
if pathErr == nil {
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
return nil, fmt.Errorf("no config found at %s - run 'gitstream add owner/repo' to get started", path)
}
}
return nil, err
}
}
return &blitCfg.Value, nil
}

if cfg.Interval <= 0 {
cfg.Interval = 30
// Save persists the current config value.
func Save(cfg *Config) error {
if blitCfg != nil {
blitCfg.Value = *cfg
return blitCfg.Save()
}
path, _ := blit.DefaultConfigPath("gitstream")
return blit.SaveYAML(path, cfg)
}

return &cfg, nil
// Editor returns a blit.ConfigEditor auto-generated from struct tags.
func Editor() *blit.ConfigEditor {
if blitCfg == nil {
if _, err := Load(); err != nil {
return nil
}
}
return blitCfg.Editor()
}

func Save(cfg *Config) error {
return blit.SaveYAML(DefaultPath(), cfg)
// CLICommands returns auto-generated CLI commands for config fields.
func CLICommands() map[string]blit.CLICommand {
if blitCfg == nil {
if _, err := Load(); err != nil {
return nil
}
}
return blitCfg.CLICommands()
}

func AddRepo(repo string) error {
Expand All @@ -109,6 +125,10 @@ func AddRepo(repo string) error {
cfg, err := Load()
if err != nil {
cfg = &Config{Interval: 30}
blitCfg, _ = blit.LoadConfig[Config]("gitstream")
if blitCfg != nil {
blitCfg.Value = *cfg
}
}

for _, r := range cfg.RepoEntries {
Expand Down
Loading
Loading