diff --git a/cmd/gitflow/root.go b/cmd/gitflow/root.go index c3dd12c..6902eec 100644 --- a/cmd/gitflow/root.go +++ b/cmd/gitflow/root.go @@ -27,6 +27,7 @@ repositories that need attention.`, root.AddCommand( newScanCmd(), newWatchCmd(), + newTUICmd(), newConfigCmd(), newVersionCmd(), newCompletionCmd(root), diff --git a/cmd/gitflow/tui.go b/cmd/gitflow/tui.go new file mode 100644 index 0000000..e0cff2b --- /dev/null +++ b/cmd/gitflow/tui.go @@ -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 +} diff --git a/go.mod b/go.mod index 30b516e..8240226 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 4aa9e03..1258432 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/presenter/compact.go b/internal/presenter/compact.go index 8b46798..4099f9a 100644 --- a/internal/presenter/compact.go +++ b/internal/presenter/compact.go @@ -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 { diff --git a/internal/presenter/presenter.go b/internal/presenter/presenter.go index 63febf8..bb372dd 100644 --- a/internal/presenter/presenter.go +++ b/internal/presenter/presenter.go @@ -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) +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..5a6aff8 --- /dev/null +++ b/internal/tui/styles.go @@ -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" } diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..94dff39 --- /dev/null +++ b/internal/tui/tui.go @@ -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] + "…" +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..d2f3ece --- /dev/null +++ b/internal/tui/tui_test.go @@ -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) + } +}