Skip to content
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
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A neon-themed terminal dashboard that polls GitHub via the local `gh` CLI and sh

## Features

- Live-refreshing TUI (Bubble Tea) with a neon truecolor palette and gradients
- Live-refreshing TUI (Bubble Tea) with built-in dark/light neon themes and gradients
- Polls every N seconds (default 20s, configurable in-app)
- Tracks **only PRs you authored** across a configurable list of repositories
- Shows per-PR:
Expand All @@ -16,6 +16,7 @@ A neon-themed terminal dashboard that polls GitHub via the local `gh` CLI and sh
- `[DRAFT]` chip for draft PRs (still listed, not filtered out)
- `[STALE]` chip for PRs created more than 4 weeks ago
- In-app menu for **managing repositories** (add/remove) and **settings** (poll interval)
- Theme picker with best-effort light/dark auto-detection plus user `*.theme.yaml` files
- Config persisted as YAML; atomic saves so a crash can't corrupt the file
- Open a highlighted PR in your browser with one keystroke

Expand Down Expand Up @@ -104,6 +105,11 @@ repos:
- owner/repo-b
poll_interval_seconds: 20
group_by_repo: false
theme:
selected: auto # auto, neon-dark, neon-light, or a user theme id
directory: ~/.config/github-butler/themes
colors:
accent: "#FF00FF"
```

### Available settings
Expand All @@ -113,11 +119,47 @@ group_by_repo: false
| `repos` | list | `[]` | List of `owner/repo` slugs to track |
| `poll_interval_seconds` | int | `20` | How often the app polls GitHub (min `2`, max `3600`) |
| `group_by_repo` | bool | `false` | When `true`, PRs are grouped under per-repo section headers instead of one flat updated list |
| `theme.selected` | str | `auto` | Theme id to use (`auto`, `neon-dark`, `neon-light`, or a user theme filename without suffix) |
| `theme.directory` | str | XDG dir | Directory scanned for `*.theme.yaml` user themes |
| `theme.colors` | map | `{}` | Optional config-level color overrides applied after the selected theme |

All three are editable from inside the app (`m` → **Settings** / **Repositories**), so you rarely need to hand-edit the YAML — but doing so works too.
Repositories and settings are editable from inside the app (`m` → **Settings** / **Repositories**), so you rarely need to hand-edit the YAML — but doing so works too.

On first launch with no config, the app opens to an empty dashboard; press `m` → Repositories → `a` to add one.

### Themes

`auto` uses Lipgloss/termenv to detect whether your terminal background is dark or light, then chooses the built-in `neon-dark` or `neon-light` theme. Detection depends on terminal support, so you can force either built-in theme from **Settings** → **Theme**.

User themes live in `~/.config/github-butler/themes` by default. The app seeds bundled examples there on startup:

- `midnight-neon.theme.yaml`
- `daylight-neon.theme.yaml`
- `high-contrast-dark.theme.yaml`
- `high-contrast-light.theme.yaml`

Self-updates seed newer examples after restart. If you changed one of the bundled examples, the app preserves your file and writes the updated example next to it with a `.new` suffix.

Theme files are YAML partial overrides layered over a built-in base:

```yaml
name: High Contrast Blue
base: neon-light
colors:
accent: "#0047AB"
success: "#006B3C"
warning: "#805A00"
danger: "#B00020"
selected_background: "#0047AB"
gradients:
title:
- "#0047AB"
- "#0077CC"
- "#4B0082"
```

Supported color keys include `accent`, `info`, `success`, `warning`, `danger`, `selected_foreground`, `selected_background`, `chip_foreground`, and the raw palette slots (`neon_pink`, `neon_cyan`, `neon_magenta`, `neon_lime`, `neon_purple`, `neon_orange`, `neon_yellow`, `neon_blue`, `hot_pink`, `black`, `white`, `dim`, `dark_bg`). Supported gradients are `title`, `countdown`, and `header`.

## Key bindings

### Dashboard
Expand All @@ -131,7 +173,7 @@ On first launch with no config, the app opens to an empty dashboard; press `m`
| `m` | Open main menu |
| `q`, Ctrl+C | Quit |

### Menu / Repositories / Settings
### Menu / Repositories / Settings / Theme Picker

| Key | Action |
| --------- | ------------------------------------------------------------------------- |
Expand Down Expand Up @@ -179,7 +221,7 @@ internal/
menu.go # main menu
repos.go # repo manager + add/remove flows
settings.go # settings list + interval editor
theme/ # neon palette, styles, gradient helper
theme/ # built-in themes, user theme loading, seeded examples
components/ # small reusable widgets (banner, countdown, toast, confirm)
scripts/
install.sh # curl | bash release installer
Expand Down
11 changes: 10 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/bluegardenproject/github-butler/internal/config"
"github.com/bluegardenproject/github-butler/internal/github"
"github.com/bluegardenproject/github-butler/internal/ui"
"github.com/bluegardenproject/github-butler/internal/ui/theme"
tea "github.com/charmbracelet/bubbletea"
)

Expand Down Expand Up @@ -89,9 +90,17 @@ func Run(ctx context.Context, args []string) error {
}
}
cfg.Path = path
cfg.Theme.Directory = cfg.ThemeDirectory()

if err := theme.SeedExamples(cfg.Theme.Directory); err != nil {
return fmt.Errorf("preparing themes: %w", err)
}
if err := theme.Activate(theme.OptionsFromConfig(cfg.Theme)); err != nil {
return fmt.Errorf("loading theme: %w", err)
}

client := github.NewClient()
model := ui.NewModel(cfg, client)
model := ui.NewModel(cfg, client, theme.Choices(cfg.Theme.Directory))

p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithContext(ctx))
_, err = p.Run()
Expand Down
35 changes: 35 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,20 @@ type Config struct {
Repos []string `yaml:"repos"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
GroupByRepo bool `yaml:"group_by_repo"`
Theme Theme `yaml:"theme"`

// Path is the file this config was loaded from (or should be saved to).
// Not persisted to YAML.
Path string `yaml:"-"`
}

// Theme is the user-facing theme configuration persisted as YAML.
type Theme struct {
Selected string `yaml:"selected"`
Directory string `yaml:"directory"`
Colors map[string]string `yaml:"colors,omitempty"`
}

// PollInterval returns the poll interval as a time.Duration, falling back
// to the default if the stored value is zero or outside the allowed range.
func (c Config) PollInterval() time.Duration {
Expand All @@ -38,12 +46,27 @@ func (c Config) PollInterval() time.Duration {

// Default returns a Config with sensible defaults but no repos.
func Default() Config {
themeDir, _ := DefaultThemeDirectory()
return Config{
Repos: []string{},
PollIntervalSeconds: int(DefaultPollInterval / time.Second),
Theme: Theme{
Selected: "auto",
Directory: themeDir,
},
}
}

// ThemeDirectory returns the directory where user theme files live. An empty
// config value falls back to the default XDG-ish theme directory.
func (c Config) ThemeDirectory() string {
if c.Theme.Directory != "" {
return c.Theme.Directory
}
dir, _ := DefaultThemeDirectory()
return dir
}

// DefaultPath returns the standard XDG-ish config file path:
// $XDG_CONFIG_HOME/github-butler/config.yaml, falling back to
// $HOME/.config/github-butler/config.yaml.
Expand All @@ -57,3 +80,15 @@ func DefaultPath() (string, error) {
}
return filepath.Join(home, ".config", "github-butler", "config.yaml"), nil
}

// DefaultThemeDirectory returns the standard directory for user theme files.
func DefaultThemeDirectory() (string, error) {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "github-butler", "themes"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "github-butler", "themes"), nil
}
43 changes: 43 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package config

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestLoadDefaultsThemeConfig(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)

path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("repos: []\npoll_interval_seconds: 20\ngroup_by_repo: false\n"), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.Theme.Selected != "auto" {
t.Fatalf("Theme.Selected = %q, want auto", cfg.Theme.Selected)
}
wantDir := filepath.Join(xdg, "github-butler", "themes")
if cfg.Theme.Directory != wantDir {
t.Fatalf("Theme.Directory = %q, want %q", cfg.Theme.Directory, wantDir)
}
}

func TestValidateThemeColors(t *testing.T) {
cfg := Default()
cfg.Theme.Colors = map[string]string{"accent": "not-a-color"}

err := Validate(cfg)
if err == nil {
t.Fatal("Validate succeeded, want error")
}
if !strings.Contains(err.Error(), "theme.colors.accent") {
t.Fatalf("Validate error = %q, want theme color path", err)
}
}
13 changes: 13 additions & 0 deletions internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ func Load(path string) (Config, error) {
if cfg.Repos == nil {
cfg.Repos = []string{}
}
if cfg.Theme.Selected == "" {
cfg.Theme.Selected = "auto"
}
if cfg.Theme.Directory == "" {
dir, err := DefaultThemeDirectory()
if err != nil {
return Config{}, err
}
cfg.Theme.Directory = dir
}
if cfg.Theme.Colors == nil {
cfg.Theme.Colors = map[string]string{}
}

if err := Validate(cfg); err != nil {
return Config{}, err
Expand Down
10 changes: 10 additions & 0 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
// must be 1..100 characters.
var repoSlugPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9._-]{1,100}$`)

var hexColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)

// Validate checks the config for obvious mistakes. Repos are checked for
// the "owner/repo" slug format; poll interval is bounds-checked.
func Validate(cfg Config) error {
Expand All @@ -32,6 +34,14 @@ func Validate(cfg Config) error {
}
seen[key] = struct{}{}
}
if strings.TrimSpace(cfg.Theme.Selected) == "" {
return fmt.Errorf("theme.selected must not be empty")
}
for key, value := range cfg.Theme.Colors {
if !hexColorPattern.MatchString(strings.TrimSpace(value)) {
return fmt.Errorf("theme.colors.%s must be a #RRGGBB hex color", key)
}
}
return nil
}

Expand Down
11 changes: 9 additions & 2 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/bluegardenproject/github-butler/internal/config"
"github.com/bluegardenproject/github-butler/internal/github"
"github.com/bluegardenproject/github-butler/internal/ui/components"
"github.com/bluegardenproject/github-butler/internal/ui/theme"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
Expand All @@ -22,6 +23,7 @@ const (
screenConfirmRemove
screenSettings
screenEditInterval
screenThemes
)

// Model is the single Bubble Tea model backing every screen. Per-screen
Expand All @@ -48,6 +50,8 @@ type Model struct {
menuCursor int
reposCursor int
settingsCursor int
themeCursor int
themeChoices []theme.Choice

// inputs
addInput textinput.Model
Expand All @@ -66,7 +70,7 @@ type Model struct {
}

// NewModel constructs the root model.
func NewModel(cfg config.Config, client *github.Client) Model {
func NewModel(cfg config.Config, client *github.Client, choices []theme.Choice) Model {
addIn := textinput.New()
addIn.Placeholder = "owner/repo or https://github.com/owner/repo"
addIn.CharLimit = 200
Expand All @@ -80,6 +84,7 @@ func NewModel(cfg config.Config, client *github.Client) Model {
return Model{
cfg: cfg,
client: client,
themeChoices: choices,
screen: screenDashboard,
addInput: addIn,
intervalInput: intervalIn,
Expand Down Expand Up @@ -177,6 +182,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateSettings(msg)
case screenEditInterval:
return m.updateEditInterval(msg)
case screenThemes:
return m.updateThemes(msg)
}
return m, nil
}
Expand All @@ -195,7 +202,7 @@ func (m Model) View() string {
body = m.viewMenu()
case screenRepos, screenAddRepo, screenConfirmRemove:
body = m.viewRepos()
case screenSettings, screenEditInterval:
case screenSettings, screenEditInterval, screenThemes:
body = m.viewSettings()
}

Expand Down
Loading
Loading