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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
- name: gofmt check (list mode — never mutates)
run: test -z "$(gofmt -l .)" || (echo "gofmt found unformatted files — run 'gofmt -w .'"; exit 1)

- name: TUI width law (never slice styled strings by byte/rune)
run: test -z "$(rg -n 'line\[1:\]|runes\[1:\]' pkg/tui/ -g '!zlist.go' || true)" || (echo "found byte/rune slicing of rendered lines in pkg/tui — use components.CursorLine"; exit 1)

test:
name: Test
runs-on: ubuntu-latest
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import (
"github.com/zaidejjo/zgit/pkg/tui"
)

var tuiMouse bool

var tuiCmd = &cobra.Command{
Use: "tui",
Short: "Launch the interactive terminal UI",
Long: `Start the Bubble Tea-based terminal UI for an interactive Git & GitHub experience.`,
RunE: func(cmd *cobra.Command, args []string) error {
return tui.Run(gitExec)
return tui.RunWithMouse(gitExec, tuiMouse)
},
}

func init() {
tuiCmd.Flags().BoolVar(&tuiMouse, "mouse", false, "enable mouse wheel scrolling in lists")
rootCmd.AddCommand(tuiCmd)
}
178 changes: 164 additions & 14 deletions pkg/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import (
"fmt"
"os"
"strings"
"sync"
"time"

"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/zaidejjo/zgit/pkg/core/config"
gitpkg "github.com/zaidejjo/zgit/pkg/core/git"
"github.com/zaidejjo/zgit/pkg/core/github"
Expand Down Expand Up @@ -383,6 +385,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Reinstall listener for next subscriber message
return updated, tea.Batch(cmd, listenForMessages(m.msgs))

case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonWheelUp:
m.scrollFocused(-3)
return m, nil
case tea.MouseButtonWheelDown:
m.scrollFocused(3)
return m, nil
}
return m, nil

case toastTickMsg:
if m.toasts.Expire() {
return m, func() tea.Msg {
Expand Down Expand Up @@ -716,6 +729,9 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.reloadLocal()
return m, nil

case "L":
return m, m.toggleTheme()

// Tab cycles focus: Status → Log → Branches → Status
case "tab":
if m.fullScreenView != shell.Panels {
Expand Down Expand Up @@ -979,20 +995,37 @@ func (m *Model) fetchDashboard() {
favs[p] = true
}

entries := make([]views.DashEntry, 0, len(paths))
for _, p := range paths {
e := views.DashEntry{Name: baseName(p), Path: p, Favorite: favs[p], Current: p == current}
if st, err := quickRepoStatus(p); err != nil {
e.Err = "unreachable"
} else {
e.Branch = st.Branch
e.Clean = st.IsClean
e.Dirty = len(st.Files)
e.Ahead = st.Ahead
e.Behind = st.Behind
}
entries = append(entries, e)
entries := make([]views.DashEntry, len(paths))
// Bounded worker pool: N sequential git stats become ~N/4 wall time.
// Order is preserved (slot per path) since the view sorts nothing.
const workers = 4
var wg sync.WaitGroup
slots := make(chan int, len(paths))
for i := range paths {
slots <- i
}
close(slots)
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range slots {
p := paths[i]
e := views.DashEntry{Name: baseName(p), Path: p, Favorite: favs[p], Current: p == current}
if st, err := quickRepoStatus(p); err != nil {
e.Err = "unreachable"
} else {
e.Branch = st.Branch
e.Clean = st.IsClean
e.Dirty = len(st.Files)
e.Ahead = st.Ahead
e.Behind = st.Behind
}
entries[i] = e
}
}()
}
wg.Wait()
m.msgs <- teaMsg{view: 5, data: dashEvent{entries: entries}}
}

Expand Down Expand Up @@ -2308,6 +2341,70 @@ type refsEvent struct {
// toastTickMsg fires when the oldest toast may have expired.
type toastTickMsg struct{}

// scrollFocused moves the focused list by delta rows (mouse wheel).
func (m *Model) scrollFocused(delta int) {
move := func(cursor *int, n int) {
*cursor += delta
if n <= 0 {
*cursor = 0
return
}
if *cursor < 0 {
*cursor = 0
}
if *cursor >= n {
*cursor = n - 1
}
}
if m.fullScreenView != shell.Panels {
switch m.fullScreenView {
case shell.PRs:
move(&m.prs.Cursor, len(m.prs.Filtered()))
case shell.Issues:
move(&m.issues.Cursor, len(m.issues.Filtered()))
case shell.Dashboard:
move(&m.dash.Cursor, len(m.dash.Filtered()))
case shell.Actions:
if m.actions.JobsFor != 0 {
move(&m.actions.Cursor, len(m.actions.Jobs))
} else {
move(&m.actions.Cursor, len(m.actions.Filtered()))
}
case shell.Notes:
move(&m.notes.Cursor, len(m.notes.Ordered()))
case shell.Worktrees:
move(&m.worktrees.Cursor, len(m.worktrees.Filtered()))
case shell.Stash:
move(&m.stash.Cursor, len(m.stash.Filtered()))
case shell.Refs:
if m.refs.Section == views.RefsTags {
move(&m.refs.Tags.Cursor, len(m.refs.Tags.Filtered()))
} else {
move(&m.refs.Remotes.Cursor, len(m.refs.Remotes.Filtered()))
}
}
return
}
switch m.focusedPanel {
case PanelStatus:
move(&m.status.Cursor, len(m.status.VisibleFiles()))
case PanelLog:
move(&m.log.Cursor, len(m.log.Commits))
case PanelBranch:
move(&m.branches.Cursor, len(m.branches.Filtered()))
}
}

// toggleTheme flips light/dark, persists, and announces it.
func (m *Model) toggleTheme() tea.Cmd {
if styles.Dark {
m.setTheme("light", true)
return m.pushToast("Light theme", components.ToneInfo)
}
m.setTheme("dark", true)
return m.pushToast("Dark theme", components.ToneInfo)
}

// pushToast shows a transient notification for ~4s.
func (m *Model) pushToast(text string, tone components.Tone) tea.Cmd {
m.toasts.Push(text, tone)
Expand Down Expand Up @@ -2622,12 +2719,65 @@ func listenForMessages(msgs <-chan teaMsg) tea.Cmd {

// Run starts the TUI application.
func Run(gitExec *gitpkg.NativeExec) error {
return RunWithMouse(gitExec, false)
}

// RunWithMouse starts the TUI, optionally reporting mouse wheel events
// for list scrolling.
func RunWithMouse(gitExec *gitpkg.NativeExec, mouse bool) error {
m := NewModel(gitExec)
m.applyTheme()

p := tea.NewProgram(m, tea.WithAltScreen())
opts := []tea.ProgramOption{tea.WithAltScreen()}
if mouse {
opts = append(opts, tea.WithMouseCellMotion())
}
p := tea.NewProgram(m, opts...)
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "TUI error: %v\n", err)
return err
}
return nil
}

// applyTheme selects light/dark from config (tui.theme) or terminal
// background detection when unset.
func (m *Model) applyTheme() {
want := ""
if cfg, err := config.New(); err == nil {
want = cfg.GetString("tui.theme")
}
if want == "" {
if termenv.HasDarkBackground() {
want = "dark"
} else {
want = "light"
}
}
m.setTheme(want, false)
}

// setTheme swaps the palette and rebuilds styles. When persist is true
// the choice is saved to config.
func (m *Model) setTheme(want string, persist bool) {
if want == "light" {
styles.SetLight()
} else {
styles.SetDark()
}
styles.Refresh()
if persist {
if cfg, err := config.New(); err == nil {
cfg.Set("tui.theme", want)
_ = cfg.Save()
}
}
}

// themeName reports the active theme for toggles and help.
func (m *Model) themeName() string {
if styles.Dark {
return "dark"
}
return "light"
}
96 changes: 77 additions & 19 deletions pkg/tui/styles/colors.go
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
// Package styles provides a clean, accessible color theme for the TUI.
// Based on Catppuccin Mocha with good contrast ratios.
// Package styles provides the TUI color theme: Catppuccin Mocha (dark)
// with a Latte (light) variant. Colors are variables so the theme can
// switch at runtime; every view reads these tokens, never literals.
package styles

import "github.com/charmbracelet/lipgloss"

// Color palette — accessible, high-contrast, modern.
const (
Base = lipgloss.Color("#1e1e2e") // dark background
Surface = lipgloss.Color("#181825") // darker surface
Overlay = lipgloss.Color("#313244") // elevated surface
Text = lipgloss.Color("#cdd6f4") // primary text
Subtext = lipgloss.Color("#a6adc8") // secondary text
Overlay0 = lipgloss.Color("#6c7086") // muted
Blue = lipgloss.Color("#89b4fa") // primary accent
Green = lipgloss.Color("#a6e3a1") // success
Yellow = lipgloss.Color("#f9e2af") // warning
Red = lipgloss.Color("#f38ba8") // error/danger
Mauve = lipgloss.Color("#cba6f7") // purple accent
Peach = lipgloss.Color("#fab387") // orange
Teal = lipgloss.Color("#94e2d5") // cyan
Rosewater = lipgloss.Color("#f5e0dc") // pinkish
// Color palette — dark (Mocha) defaults; SetLight/SetDark swap them.
var (
Base = lipgloss.Color("#1e1e2e")
Surface = lipgloss.Color("#181825")
Overlay = lipgloss.Color("#313244")
Text = lipgloss.Color("#cdd6f4")
Subtext = lipgloss.Color("#a6adc8")
Overlay0 = lipgloss.Color("#6c7086")
Blue = lipgloss.Color("#89b4fa")
Green = lipgloss.Color("#a6e3a1")
Yellow = lipgloss.Color("#f9e2af")
Red = lipgloss.Color("#f38ba8")
Mauve = lipgloss.Color("#cba6f7")
Peach = lipgloss.Color("#fab387")
Teal = lipgloss.Color("#94e2d5")
Rosewater = lipgloss.Color("#f5e0dc")

// Semantic aliases
// Semantic aliases (re-pointed on theme switch)
Success = Green
Warning = Yellow
Danger = Red
Expand All @@ -32,4 +33,61 @@ const (
// Panel border colors
ActivePanelBorderColor = Accent
InactivePanelBorderColor = Overlay0

// Dark reports whether the dark palette is active.
Dark = true
)

// SetLight swaps to the Catppuccin Latte palette.
func SetLight() {
Base = "#eff1f5"
Surface = "#e6e9ef"
Overlay = "#ccd0da"
Text = "#4c4f69"
Subtext = "#6c6f85"
Overlay0 = "#9ca0b0"
Blue = "#1e66f5"
Green = "#40a02b"
Yellow = "#df8e1d"
Red = "#d20f39"
Mauve = "#8839ef"
Peach = "#fe640b"
Teal = "#179299"
Rosewater = "#dc8a78"
Success = Green
Warning = Yellow
Danger = Red
Info = Blue
Accent = Mauve
Selection = lipgloss.Color("#acb0be")
ActivePanelBorderColor = Accent
InactivePanelBorderColor = Overlay0
Dark = false
}

// SetDark restores the Catppuccin Mocha palette.
func SetDark() {
Base = "#1e1e2e"
Surface = "#181825"
Overlay = "#313244"
Text = "#cdd6f4"
Subtext = "#a6adc8"
Overlay0 = "#6c7086"
Blue = "#89b4fa"
Green = "#a6e3a1"
Yellow = "#f9e2af"
Red = "#f38ba8"
Mauve = "#cba6f7"
Peach = "#fab387"
Teal = "#94e2d5"
Rosewater = "#f5e0dc"
Success = Green
Warning = Yellow
Danger = Red
Info = Blue
Accent = Mauve
Selection = lipgloss.Color("#45475a")
ActivePanelBorderColor = Accent
InactivePanelBorderColor = Overlay0
Dark = true
}
Loading
Loading