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:
parent
799d9464b9
commit
2656f29019
@ -27,6 +27,7 @@ repositories that need attention.`,
|
||||
root.AddCommand(
|
||||
newScanCmd(),
|
||||
newWatchCmd(),
|
||||
newTUICmd(),
|
||||
newConfigCmd(),
|
||||
newVersionCmd(),
|
||||
newCompletionCmd(root),
|
||||
|
||||
63
cmd/gitflow/tui.go
Normal file
63
cmd/gitflow/tui.go
Normal 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
21
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
|
||||
)
|
||||
|
||||
41
go.sum
41
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=
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
50
internal/tui/styles.go
Normal 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
305
internal/tui/tui.go
Normal 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
172
internal/tui/tui_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user