feat: M2 — TUI skeleton (bubbletea model/view/styles)

Ship a working `gitflow tui` command: static scan result rendered as a
lipgloss-styled table, with keyboard navigation and a detail pane.

TUI package (internal/tui):
- Model struct: holds scan result, cursor, detail/help toggles, loading
  state, error display, and injected app/AI/config dependencies
- Update: handles WindowSizeMsg (resize), KeyMsg for navigation (j/↑/k/↓,
  Home/End, Enter/Space for detail, ? for help, q/Ctrl-C for quit), and
  async scanResultMsg/scanErrorMsg for the M3 periodic scan loop
- View: repo list table (STATUS/REPOSITORY/BRANCH/AHEAD-BEHIND/CHGS/STASH)
  with cursor highlighting and dimmed error rows, expandable detail pane
  showing files, and a status bar (scan summary + keybinding hints)
- styles.go: lipgloss Styles (Header/Row/CursorRow/StatusBar) with
  dark/light theme palettes that mirror the CLI's ThemeMode
- Loading and empty states; help overlay with keybinding reference

Presenter API surface (needed by the TUI):
- ShortPath exported (home → ~ collapsing, reused by TUI and CLI)
- StatusSymbolFor exported (✓ ✗ ↑ ↓ ⇄ ◉ ▢ ! symbols, reused)
- ShortPath is kept as a wrapper so internal formatter callers are
  unaffected

CLI (cmd/gitflow):
- newTUICmd: resolves config, builds AI provider, calls tui.New() for the
  initial scan, and runs the bubbletea Program
- Wired into root command under `gitflow tui` with full flag set
  (--dir, --interval, --exclude, etc.)

Dependencies: bubbletea v1.3.10, lipgloss v1.1.0, a stable x/term tree

Testing:
- Model logic tests: cursor navigation (arrow + j/k), bottom/top clamping,
  detail toggle (Enter/Space), ? help, q/Ctrl-C quit, view renders
  repo names/branch/status columns, detail pane shows file lists, help
  overlay shows keybindings, loading/empty states

Verified: go build, go vet, go test -race (12 packages), gofmt clean,
`gitflow tui --help` prints command usage.
This commit is contained in:
dimitar 2026-08-02 09:08:10 +02:00
parent 799d9464b9
commit 2656f29019
9 changed files with 662 additions and 6 deletions

View File

@ -27,6 +27,7 @@ repositories that need attention.`,
root.AddCommand(
newScanCmd(),
newWatchCmd(),
newTUICmd(),
newConfigCmd(),
newVersionCmd(),
newCompletionCmd(root),

63
cmd/gitflow/tui.go Normal file
View File

@ -0,0 +1,63 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
"gitea.oblak.solutions/dimitar/gitFlow/internal/tui"
)
// newTUICmd launches the interactive terminal UI.
func newTUICmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tui",
Short: "Start the interactive terminal UI",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cmd.Flags())
if err != nil {
return err
}
if !cmd.Flags().Changed("dir") && isTerminal(os.Stdin) {
if d, err := promptDir(cfg.Dir); err == nil {
cfg.Dir = d
}
}
a, err := app.New(cfg)
if err != nil {
return err
}
var aiProvider ai.Provider
if cfg.AI.Enabled {
aiProvider, err = ai.NewProvider(cfg.AI)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
}
}
opts := presenter.Options{
Color: presenter.ParseColorMode(cfg.Color),
Theme: presenter.ParseTheme(cfg.Theme),
}
p, err := tui.New(cfg, a, aiProvider, opts)
if err != nil {
return fmt.Errorf("tui: %w", err)
}
if _, err := p.Run(); err != nil {
return fmt.Errorf("tui: %w", err)
}
return nil
},
}
config.RegisterFlags(cmd.Flags())
return cmd
}

21
go.mod
View File

@ -1,8 +1,10 @@
module gitea.oblak.solutions/dimitar/gitFlow
go 1.24
go 1.24.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/spf13/viper v1.19.0
@ -11,22 +13,37 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.7 // 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.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

41
go.sum
View File

@ -1,8 +1,24 @@
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/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
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/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@ -17,15 +33,32 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -57,6 +90,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
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=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
@ -65,8 +100,10 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -38,6 +38,12 @@ func (c *CompactFormatter) Format(w io.Writer, result status.ScanResult) error {
return nil
}
// StatusSymbolFor returns a single-character status marker for the given
// status, shared by the compact formatter and the TUI.
func StatusSymbolFor(s status.RepoStatus) string {
return statusSymbol(s)
}
// statusSymbol returns a single-character status marker.
func statusSymbol(s status.RepoStatus) string {
switch s {

View File

@ -170,8 +170,8 @@ func isTTY(w io.Writer) bool {
return info.Mode()&os.ModeCharDevice != 0
}
// shortPath renders a path with the home directory collapsed to "~".
func shortPath(p string) string {
// ShortPath renders a path with the home directory collapsed to "~".
func ShortPath(p string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return p
@ -182,3 +182,8 @@ func shortPath(p string) string {
}
return filepath.Join("~", rel)
}
// shortPath is the internal name used by formatters; kept for compatibility.
func shortPath(p string) string {
return ShortPath(p)
}

50
internal/tui/styles.go Normal file
View File

@ -0,0 +1,50 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
)
// Styles groups the lipgloss styles used across TUI views.
type Styles struct {
Header lipgloss.Style
Row lipgloss.Style
CursorRow lipgloss.Style
StatusBar lipgloss.Style
}
// Dim applies a dimmed, lower-contrast style (shared helper).
func (s Styles) Dim(text string) string {
return lipgloss.NewStyle().Faint(true).Render(text)
}
// Err applies an error-highlight style.
func (s Styles) Err(text string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render(text)
}
// NewStyles builds a Styles palette from the presenter options so the TUI
// theme matches the CLI.
func NewStyles(opts presenter.Options) Styles {
headerFg, rowFg, cursorFg, barFg := darkColors()
if opts.Theme == presenter.ThemeLight {
headerFg, rowFg, cursorFg, barFg = lightColors()
}
return Styles{
Header: lipgloss.NewStyle().
Foreground(lipgloss.Color(headerFg)).
Bold(true),
Row: lipgloss.NewStyle().
Foreground(lipgloss.Color(rowFg)),
CursorRow: lipgloss.NewStyle().
Foreground(lipgloss.Color(cursorFg)).
Background(lipgloss.Color("235")),
StatusBar: lipgloss.NewStyle().
Foreground(lipgloss.Color(barFg)).
Background(lipgloss.Color("236")),
}
}
func darkColors() (h, r, c, b string) { return "14", "7", "15", "8" }
func lightColors() (h, r, c, b string) { return "6", "0", "0", "7" }

305
internal/tui/tui.go Normal file
View File

@ -0,0 +1,305 @@
// Package tui provides an interactive terminal UI for browsing gitflow scan
// results using bubbletea.
package tui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbletea"
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// Model is the top-level bubbletea model for the TUI.
type Model struct {
app *app.App
ai ai.Provider // nil when AI is disabled
cfg *config.Config
opts presenter.Options
result status.ScanResult
cursor int
detail bool
help bool
aiPanel bool
width int
height int
loading bool
aiLoading bool
err string
first bool
styles Styles
}
// New builds a bubbletea Program for the TUI. It runs an initial scan
// synchronously so the first frame shows results immediately.
func New(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) (*tea.Program, error) {
result, err := a.ScanOnce(context.Background())
if err != nil {
return nil, fmt.Errorf("tui: initial scan: %w", err)
}
return newProgram(cfg, a, aiProvider, opts, result, false), nil
}
// NewEmpty returns a Program that shows an empty state and runs a scan on
// Init (for testing the async flow).
func NewEmpty(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) *tea.Program {
return newProgram(cfg, a, aiProvider, opts, status.ScanResult{}, true)
}
func newProgram(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options, result status.ScanResult, loading bool) *tea.Program {
m := Model{
app: a,
ai: aiProvider,
cfg: cfg,
opts: opts,
result: result,
cursor: 0,
detail: false,
first: true,
styles: NewStyles(opts),
}
if loading {
m.loading = true
}
return tea.NewProgram(m, tea.WithAltScreen())
}
// Init is the initial command.
func (m Model) Init() tea.Cmd {
if m.loading {
return m.scanCmd
}
return nil
}
// scanCmd triggers a scan in the background.
func (m Model) scanCmd() tea.Msg {
result, err := m.app.ScanOnce(context.Background())
if err != nil {
return scanErrorMsg{err}
}
return scanResultMsg{result}
}
// ---- messages --------------------------------------------------------------
type scanTickMsg struct{}
type scanResultMsg struct{ result status.ScanResult }
type scanErrorMsg struct{ err error }
type terminalResizeMsg struct{ width, height int }
// ---- update ----------------------------------------------------------------
// Update handles incoming messages.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "?":
m.help = !m.help
case "down", "j":
if m.cursor < len(m.result.Repos)-1 {
m.cursor++
}
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "enter", " ":
m.detail = !m.detail
}
case scanResultMsg:
m.result = msg.result
m.loading = false
m.err = ""
case scanErrorMsg:
m.err = msg.err.Error()
m.loading = false
}
return m, nil
}
// ---- view ------------------------------------------------------------------
// View renders the full TUI.
func (m Model) View() string {
if m.help {
return m.helpView()
}
var b strings.Builder
b.WriteString(m.repoListView())
b.WriteString("\n")
if m.detail && m.cursor < len(m.result.Repos) {
b.WriteString(m.detailView(m.result.Repos[m.cursor]))
b.WriteString("\n")
}
b.WriteString(m.statusBarView())
return b.String()
}
// ---- repo list -------------------------------------------------------------
func (m Model) repoListView() string {
if m.loading {
return fmt.Sprintf("\n %s scanning %s...\n", m.styles.Dim("⏳"), m.cfg.Dir)
}
if len(m.result.Repos) == 0 {
return fmt.Sprintf("\n %s\n", m.styles.Dim("no repositories found under "+m.cfg.Dir))
}
var b strings.Builder
header := m.styles.Header
// Column widths
fmt.Fprintf(&b, "%s%-5s %-30s %-15s %-10s %5s %6s\n%s",
header.Render(""),
header.Render("STATUS"),
header.Render("REPOSITORY"),
header.Render("BRANCH"),
header.Render("AHEAD/BEHIND"),
header.Render("CHGS"),
header.Render("STASH"),
header.Render(""),
)
for i, repo := range m.result.Repos {
cursor := " "
if i == m.cursor {
cursor = ">"
}
ab := "-"
if repo.AheadBy > 0 || repo.BehindBy > 0 {
ab = fmt.Sprintf("%d/%d", repo.AheadBy, repo.BehindBy)
}
chs := "-"
if n := repo.FileCount(); n > 0 {
chs = fmt.Sprintf("%d", n)
}
stash := "-"
if repo.StashCount > 0 {
stash = fmt.Sprintf("%d", repo.StashCount)
}
line := fmt.Sprintf("%s %-5s %-30s %-15s %5s %6s %6s\n",
cursor,
presenter.StatusSymbolFor(repo.Status),
truncate(shortPath(repo.Path), 29),
truncate(repo.Branch, 14),
ab,
chs,
stash,
)
line = m.colorLine(repo.Status, cursor, line)
if i == m.cursor {
b.WriteString(m.styles.CursorRow.Render(line))
} else {
b.WriteString(m.styles.Row.Render(line))
}
}
return b.String()
}
func (m Model) colorLine(s status.RepoStatus, cursor string, line string) string {
// Dim errored repos; use status color for the status symbol column.
switch s {
case status.StatusError:
return m.styles.Dim(line)
case status.StatusClean:
// keep default
}
return line
}
// ---- detail view -----------------------------------------------------------
func (m Model) detailView(repo status.RepoInfo) string {
var b strings.Builder
w := m.width
if w < 40 {
w = 80 // default when no resize event yet (e.g. tests)
}
sep := m.styles.Dim(strings.Repeat("─", max(0, w-2)))
b.WriteString(sep + "\n")
line := fmt.Sprintf(" %s branch: %s | remote: %s | ahead: %d behind: %d",
repo.Path, repo.Branch, truncate(repo.RemoteURL, 30), repo.AheadBy, repo.BehindBy)
b.WriteString(m.styles.Dim(line) + "\n")
d := fmt.Sprintf(" staged: %d modified: %d untracked: %d stash: %d",
len(repo.StagedFiles), len(repo.ModifiedFiles), len(repo.UntrackedFiles), repo.StashCount)
b.WriteString(d + "\n")
if repo.Error != "" {
b.WriteString(m.styles.Err(repo.Error) + "\n")
}
if len(repo.StagedFiles) > 0 {
b.WriteString(" staged: " + strings.Join(repo.StagedFiles, ", ") + "\n")
}
if len(repo.ModifiedFiles) > 0 {
b.WriteString(" modified: " + strings.Join(repo.ModifiedFiles, ", ") + "\n")
}
if len(repo.UntrackedFiles) > 0 {
b.WriteString(" untracked: " + strings.Join(repo.UntrackedFiles, ", ") + "\n")
}
return b.String()
}
// ---- status bar ------------------------------------------------------------
func (m Model) statusBarView() string {
s := m.result.Summary()
left := fmt.Sprintf("%d repos | %d clean | %d need attention | %d errors",
s.Total, s.Clean, s.Attention, s.Errored)
if m.err != "" {
left += " [error: " + m.err + "]"
}
right := "? help q quit ↑↓ nav Enter detail"
bar := left + strings.Repeat(" ", max(0, m.width-len(left)-len(right))) + right
return m.styles.StatusBar.Render(bar)
}
// ---- help ------------------------------------------------------------------
func (m Model) helpView() string {
return m.styles.Dim(
"\n KEYBINDINGS\n" +
" ↑/↓ or j/k navigate\n" +
" Enter / Space toggle detail pane\n" +
" a AI suggestions (requires --ai)\n" +
" ? toggle this help\n" +
" q / Ctrl-C quit\n",
)
}
// ---- helpers ---------------------------------------------------------------
func shortPath(p string) string {
return presenter.ShortPath(p)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}

172
internal/tui/tui_test.go Normal file
View File

@ -0,0 +1,172 @@
package tui
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/bubbletea"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
func newTestModel() Model {
return newTestModelWith(status.ScanResult{
ScannedAt: time.Now(),
ParentDir: "/tmp/test",
Repos: []status.RepoInfo{
{Path: "/tmp/test/a", Name: "a", Branch: "main", Status: status.StatusClean},
{Path: "/tmp/test/b", Name: "b", Branch: "feat/x", Status: status.StatusModified, ModifiedFiles: []string{"f.go"}},
{Path: "/tmp/test/c", Name: "c", Status: status.StatusError, Error: "boom"},
},
})
}
func newTestModelWith(result status.ScanResult) Model {
return Model{
app: nil,
cfg: &config.Config{Dir: "/tmp/test", Format: "table", Color: "auto", Theme: "dark", Workers: 4},
opts: presenter.Options{Color: presenter.ColorNever, Theme: presenter.ThemeDark},
result: result,
cursor: 0,
first: true,
styles: NewStyles(presenter.Options{Theme: presenter.ThemeDark}),
}
}
func TestModelNavigation(t *testing.T) {
m := newTestModel()
// Move down
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
if m2.(Model).cursor != 1 {
t.Errorf("cursor = %d after down, want 1", m2.(Model).cursor)
}
// Move down again
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyDown})
if m3.(Model).cursor != 2 {
t.Errorf("cursor = %d after down, want 2", m3.(Model).cursor)
}
// At bottom: cursor stays
m4, _ := m3.Update(tea.KeyMsg{Type: tea.KeyDown})
if m4.(Model).cursor != 2 {
t.Errorf("cursor = %d after down (at bottom), want 2", m4.(Model).cursor)
}
// Move up
m5, _ := m4.Update(tea.KeyMsg{Type: tea.KeyUp})
if m5.(Model).cursor != 1 {
t.Errorf("cursor = %d after up, want 1", m5.(Model).cursor)
}
// At top: cursor stays
m6, _ := m5.Update(tea.KeyMsg{Type: tea.KeyUp})
m6, _ = m6.Update(tea.KeyMsg{Type: tea.KeyUp})
if m6.(Model).cursor != 0 {
t.Errorf("cursor = %d after up (at top), want 0", m6.(Model).cursor)
}
}
func TestModelJKKeys(t *testing.T) {
m := newTestModel()
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
if m2.(Model).cursor != 1 {
t.Errorf("cursor = %d after j, want 1", m2.(Model).cursor)
}
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
if m3.(Model).cursor != 0 {
t.Errorf("cursor = %d after k, want 0", m3.(Model).cursor)
}
}
func TestModelDetailToggle(t *testing.T) {
m := newTestModel()
if m.detail {
t.Error("detail = true initially, want false")
}
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if !m2.(Model).detail {
t.Error("detail = false after Enter, want true")
}
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeySpace})
if m3.(Model).detail {
t.Error("detail = true after Space, want false")
}
}
func TestModelHelpToggle(t *testing.T) {
m := newTestModel()
if m.help {
t.Error("help = true initially, want false")
}
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
if !m2.(Model).help {
t.Error("help = false after ?, want true")
}
}
func TestModelQuit(t *testing.T) {
m := newTestModel()
for _, key := range []tea.KeyType{tea.KeyCtrlC} {
_, cmd := m.Update(tea.KeyMsg{Type: key})
if cmd == nil {
t.Errorf("no quit cmd for key %v", key)
continue
}
// tea.Quit is a function that returns a tea.QuitMsg
if cmd() == nil {
t.Errorf("quit cmd produced nil msg")
}
}
// 'q' quit
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
if cmd == nil {
t.Error("no quit cmd for 'q'")
}
}
func TestModelViewRendersRepos(t *testing.T) {
m := newTestModel()
view := m.View()
for _, want := range []string{"a", "b", "c", "feat/x", "STATUS", "BRANCH"} {
if !strings.Contains(view, want) {
t.Errorf("view missing %q:\n%s", want, view)
}
}
}
func TestModelViewShowsHelp(t *testing.T) {
m := newTestModel()
m.help = true
view := m.View()
if !strings.Contains(view, "KEYBINDINGS") {
t.Errorf("help view missing KEYBINDINGS:\n%s", view)
}
}
func TestModelViewShowsDetail(t *testing.T) {
m := newTestModel()
m.cursor = 1 // select repo 'b'
m.detail = true
view := m.View()
if !strings.Contains(view, "modified:") || !strings.Contains(view, "f.go") {
t.Errorf("detail view missing file:\n%s", view)
}
}
func TestModelViewLoading(t *testing.T) {
m := newTestModel()
m.loading = true
view := m.View()
if !strings.Contains(view, "scanning") {
t.Errorf("loading view missing 'scanning':\n%s", view)
}
}
func TestModelViewEmpty(t *testing.T) {
m := newTestModelWith(status.ScanResult{Repos: nil})
view := m.View()
if !strings.Contains(view, "no repositories") {
t.Errorf("empty view missing 'no repositories':\n%s", view)
}
}