From 0ae44e9a648960b5cc35c1e08d67c1e98133bcb0 Mon Sep 17 00:00:00 2001 From: wbtiger <28288271@qq.com> Date: Sun, 17 May 2026 14:08:10 +0800 Subject: [PATCH] Add TUI foundation (Phase 1: Harness Foundation) Core architecture: - tui/app.go: Root Bubble Tea model with view routing, layout engine, global keybindings (Ctrl+A/Ctrl+Space/Ctrl+P/?), AI panel toggle - tui/store/store.go: Central state store with auth detection and project context resolution (reuses gitlink-cli internal packages) - tui/navigation/router.go: Stack-based view navigation for back support - tui/ui/: Theme, keymap, styles, StatusBar, TabBar components - tui/views/home.go: Dashboard view with onboarding wizard for first run - main.go: TUI mode dispatch via "gitlink-cli tui" Entry point: gitlink-cli tui Co-Authored-By: Claude Opus 4.7 --- go.mod | 23 +++ go.sum | 48 ++++++ main.go | 27 +++ tui/app.go | 347 +++++++++++++++++++++++++++++++++++++++ tui/navigation/router.go | 55 +++++++ tui/store/store.go | 141 ++++++++++++++++ tui/ui/components.go | 84 ++++++++++ tui/ui/keymap.go | 42 +++++ tui/ui/styles.go | 33 ++++ tui/ui/theme.go | 28 ++++ tui/views/home.go | 141 ++++++++++++++++ 11 files changed, 969 insertions(+) create mode 100644 tui/app.go create mode 100644 tui/navigation/router.go create mode 100644 tui/store/store.go create mode 100644 tui/ui/components.go create mode 100644 tui/ui/keymap.go create mode 100644 tui/ui/styles.go create mode 100644 tui/ui/theme.go create mode 100644 tui/views/home.go diff --git a/go.mod b/go.mod index 19ce345..81d28af 100644 --- a/go.mod +++ b/go.mod @@ -10,11 +10,34 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // 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.19 // 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/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.3.8 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index af5e2c6..12b3194 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,62 @@ +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/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +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.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +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.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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= @@ -27,13 +69,19 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index 9e9f756..9cb0b5a 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,39 @@ package main import ( + "fmt" "os" + tea "github.com/charmbracelet/bubbletea" + "github.com/gitlink-org/gitlink-cli/cmd" + "github.com/gitlink-org/gitlink-cli/tui" ) func main() { + if len(os.Args) > 1 && os.Args[1] == "tui" { + // TUI mode + f, err := tea.LogToFile("gitlink-tui.log", "") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to init log: %v\n", err) + os.Exit(1) + } + defer f.Close() + + app := tui.NewApp() + program := tea.NewProgram( + app, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + if _, err := program.Run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + return + } + + // CLI mode if err := cmd.Execute(); err != nil { os.Exit(1) } diff --git a/tui/app.go b/tui/app.go new file mode 100644 index 0000000..c82ab69 --- /dev/null +++ b/tui/app.go @@ -0,0 +1,347 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/gitlink-org/gitlink-cli/tui/navigation" + "github.com/gitlink-org/gitlink-cli/tui/store" + "github.com/gitlink-org/gitlink-cli/tui/ui" + "github.com/gitlink-org/gitlink-cli/tui/views" +) + +// View enum +type View int + +const ( + ViewHome View = iota + ViewCode + ViewIssueBoard + ViewIssueDetail + ViewPRList + ViewPRDetail + ViewCIDashboard + ViewCILog + ViewReleaseList + ViewReleaseCreate + ViewPMDashboard + ViewExecutionDashboard + ViewSearch + ViewSettings +) + +// AppModel is the root Bubble Tea model. +type AppModel struct { + // Navigation + currentView View + router *navigation.Router + + // Sub-models for each view + home *views.HomeModel + codeView tea.Model + issueBoard tea.Model + issueDetail tea.Model + prList tea.Model + prDetail tea.Model + ciDashboard tea.Model + ciLog tea.Model + releaseList tea.Model + releaseCreate tea.Model + pmDashboard tea.Model + execDashboard tea.Model + searchView tea.Model + settingsView tea.Model + + // Layout + width int + height int + ready bool + + // Panels + statusBar *ui.StatusBarModel + tabBar *ui.TabBarModel + aiPanel tea.Model // nil = not created yet (Phase 3) + aiOpen bool + + // Shared state + store *store.Store + + // Key bindings + keyMap ui.KeyMap + + // Notifications + notifications []string +} + +// tabs defines the global tab bar. +var tabs = []ui.TabDef{ + {Key: "1", Label: "Home"}, + {Key: "2", Label: "Code"}, + {Key: "3", Label: "Issues"}, + {Key: "4", Label: "PRs"}, + {Key: "5", Label: "CI"}, + {Key: "6", Label: "Release"}, + {Key: "7", Label: "PM"}, +} + +func NewApp() *AppModel { + s := store.New() + + statusBar := ui.NewStatusBar() + tabBar := ui.NewTabBar(tabs) + + return &AppModel{ + currentView: ViewHome, + router: navigation.NewRouter(), + store: s, + statusBar: statusBar, + tabBar: tabBar, + keyMap: ui.DefaultKeyMap, + home: views.NewHomeModel(s), + } +} + +func (m *AppModel) Init() tea.Cmd { + // Load auth status and current project info + return tea.Batch( + m.store.LoadAuthStatus, + m.store.LoadProjectContext, + ) +} + +func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if !m.ready { + m.ready = true + } + return m, nil + + case tea.KeyMsg: + // Global keybindings take priority over view-specific ones + cmd := m.handleGlobalKeys(msg) + if cmd != nil { + return m, cmd + } + + case store.AuthStatusMsg: + // Update statusbar with auth info + _ = msg + case store.ProjectContextMsg: + // Update statusbar with project info + m.statusBar.SetProject(msg.Owner + "/" + msg.Repo) + } + cmds = nil + + // Delegate to active view + viewModel := m.currentViewModel() + if viewModel != nil { + newModel, cmd := viewModel.Update(msg) + // If the model returned a different model (unlikely but handle) + _ = newModel + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + return m, tea.Batch(cmds...) +} + +func (m *AppModel) View() string { + if !m.ready { + return "Initializing..." + } + + // Compute layout dimensions + statusHeight := 1 + tabHeight := 1 + + mainHeight := m.height - statusHeight - tabHeight + if mainHeight < 0 { + mainHeight = 0 + } + + mainWidth := m.width + aiWidth := 0 + if m.aiOpen && m.aiPanel != nil { + aiWidth = min(40, m.width*30/100) + mainWidth = m.width - aiWidth + } + + // Render active view + var mainContent string + viewModel := m.currentViewModel() + if viewModel != nil { + mainContent = viewModel.View() + } + mainBox := lipgloss.NewStyle(). + Width(mainWidth). + Height(mainHeight). + Render(mainContent) + + // AI panel placeholder + var aiContent string + if m.aiOpen { + aiContent = lipgloss.NewStyle(). + Width(aiWidth). + Height(mainHeight). + BorderLeft(true). + BorderStyle(lipgloss.NormalBorder()). + Padding(0, 1). + Render("AI Panel\n───\nCtrl+Space to toggle") + } + + // Combine main + AI + topSection := lipgloss.JoinHorizontal(lipgloss.Top, mainBox, aiContent) + + // Tab bar + var tabBarStr string + if !m.aiOpen || aiWidth == 0 { + // Highlight active tab + tabBarStr = m.tabBar.View(int(m.currentView)) + } else { + tabBarStr = m.tabBar.View(int(m.currentView)) + } + + // Status bar + statusStr := m.statusBar.View() + + return lipgloss.JoinVertical( + lipgloss.Left, + topSection, + tabBarStr, + statusStr, + ) +} + +func (m *AppModel) handleGlobalKeys(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "ctrl+c": + return tea.Quit + + case "ctrl+space": + m.aiOpen = !m.aiOpen + return nil + + case "ctrl+p": + // Command palette (Phase 5, placeholder) + m.notifications = append(m.notifications, "Command palette coming in Phase 5") + return nil + + case "?": + // Help overlay (placeholder) + m.notifications = append(m.notifications, "Help: j/k navigate, Enter select, Esc back, Ctrl+A AI actions") + return nil + + case "ctrl+a": + // AI context menu (Phase 3, placeholder) + m.notifications = append(m.notifications, fmt.Sprintf("AI menu for: %s", m.viewName())) + return nil + + case "1", "2", "3", "4", "5", "6", "7": + return m.switchToTab(msg.String()) + + case "tab": + // Cycle through views + next := (int(m.currentView) + 1) % len(tabs) + m.currentView = View(next) + return nil + } + + // Delegate to view + return nil +} + +func (m *AppModel) switchToTab(key string) tea.Cmd { + switch key { + case "1": + m.currentView = ViewHome + case "2": + m.currentView = ViewCode + case "3": + m.currentView = ViewIssueBoard + case "4": + m.currentView = ViewPRList + case "5": + m.currentView = ViewCIDashboard + case "6": + m.currentView = ViewReleaseList + case "7": + m.currentView = ViewPMDashboard + } + return nil +} + +func (m *AppModel) currentViewModel() tea.Model { + switch m.currentView { + case ViewHome: + return m.home + case ViewCode: + return m.codeView + case ViewIssueBoard: + return m.issueBoard + case ViewIssueDetail: + return m.issueDetail + case ViewPRList: + return m.prList + case ViewPRDetail: + return m.prDetail + case ViewCIDashboard: + return m.ciDashboard + case ViewCILog: + return m.ciLog + case ViewReleaseList: + return m.releaseList + case ViewReleaseCreate: + return m.releaseCreate + case ViewPMDashboard: + return m.pmDashboard + case ViewExecutionDashboard: + return m.execDashboard + case ViewSearch: + return m.searchView + case ViewSettings: + return m.settingsView + } + return nil +} + +func (m *AppModel) viewName() string { + names := map[View]string{ + ViewHome: "Home", + ViewCode: "Code", + ViewIssueBoard: "Issues", + ViewIssueDetail: "Issue Detail", + ViewPRList: "PRs", + ViewPRDetail: "PR Detail", + ViewCIDashboard: "CI", + ViewCILog: "CI Log", + ViewReleaseList: "Releases", + ViewReleaseCreate: "New Release", + ViewPMDashboard: "PM", + ViewExecutionDashboard: "Execution", + ViewSearch: "Search", + ViewSettings: "Settings", + } + if n, ok := names[m.currentView]; ok { + return n + } + return "Unknown" +} + +func (m *AppModel) navigate(to View) { + m.router.Push(int(m.currentView)) + m.currentView = to +} + +func (m *AppModel) goBack() { + if prev, ok := m.router.Pop(); ok { + m.currentView = View(prev) + } +} diff --git a/tui/navigation/router.go b/tui/navigation/router.go new file mode 100644 index 0000000..c044bee --- /dev/null +++ b/tui/navigation/router.go @@ -0,0 +1,55 @@ +package navigation + +// Router provides a stack-based view history for back navigation. +type Router struct { + stack []int + params map[int]map[string]interface{} // per-view params +} + +func NewRouter() *Router { + return &Router{ + stack: make([]int, 0, 16), + params: make(map[int]map[string]interface{}), + } +} + +// Push saves the current view index for later back navigation. +func (r *Router) Push(view int) { + r.stack = append(r.stack, view) +} + +// Pop returns the previous view, or false if the stack is empty. +func (r *Router) Pop() (int, bool) { + if len(r.stack) == 0 { + return 0, false + } + prev := r.stack[len(r.stack)-1] + r.stack = r.stack[:len(r.stack)-1] + return prev, true +} + +// Depth returns the current stack depth. +func (r *Router) Depth() int { + return len(r.stack) +} + +// SetParam sets a parameter for a view. +func (r *Router) SetParam(view int, key string, value interface{}) { + if r.params[view] == nil { + r.params[view] = make(map[string]interface{}) + } + r.params[view][key] = value +} + +// GetParam gets a parameter for a view. +func (r *Router) GetParam(view int, key string) interface{} { + if p := r.params[view]; p != nil { + return p[key] + } + return nil +} + +// ClearParams clears all params for a view. +func (r *Router) ClearParams(view int) { + delete(r.params, view) +} diff --git a/tui/store/store.go b/tui/store/store.go new file mode 100644 index 0000000..ec5f39b --- /dev/null +++ b/tui/store/store.go @@ -0,0 +1,141 @@ +package store + +import ( + "fmt" + "sync" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/gitlink-org/gitlink-cli/internal/auth" + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/context" +) + +// Store is the central state container. +type Store struct { + mu sync.RWMutex + + // Client for GitLink API calls + Client *client.Client + + // Config + Config *config.Config + + // Auth + Token string + LoggedIn bool + User *UserInfo + + // Project + Owner string + Repo string + + // Subscribers + subs map[string][]chan tea.Msg +} + +// UserInfo holds basic authenticated user data. +type UserInfo struct { + Login string + ID int + Username string +} + +func New() *Store { + cli, err := client.New() + if err != nil { + cli = nil + } + + return &Store{ + Client: cli, + subs: make(map[string][]chan tea.Msg), + } +} + +// LoadAuthStatus checks whether the user is authenticated. +func (s *Store) LoadAuthStatus() tea.Msg { + token, err := auth.LoadToken() + if err != nil { + return AuthStatusMsg{LoggedIn: false, Error: err} + } + + s.mu.Lock() + s.Token = token + s.LoggedIn = token != "" + s.mu.Unlock() + + return AuthStatusMsg{LoggedIn: token != ""} +} + +// LoadProjectContext detects the current GitLink project from git remote. +func (s *Store) LoadProjectContext() tea.Msg { + owner, repo, err := context.ResolveOwnerRepo("", "") + if err != nil { + return ProjectContextMsg{Error: err} + } + + s.mu.Lock() + s.Owner = owner + s.Repo = repo + s.mu.Unlock() + + return ProjectContextMsg{ + Owner: owner, + Repo: repo, + } +} + +// Dispatch sends an action to the store and publishes to subscribers. +func (s *Store) Dispatch(action string, data interface{}) { + s.mu.RLock() + subs := s.subs[action] + s.mu.RUnlock() + + for _, ch := range subs { + select { + case ch <- StoreEvent{Action: action, Data: data}: + default: + } + } +} + +// Subscribe returns a channel for store events. +func (s *Store) Subscribe(action string) chan tea.Msg { + ch := make(chan tea.Msg, 10) + s.mu.Lock() + s.subs[action] = append(s.subs[action], ch) + s.mu.Unlock() + return ch +} + +// AuthStatusMsg is sent when auth status is loaded. +type AuthStatusMsg struct { + LoggedIn bool + Error error +} + +// ProjectContextMsg is sent when project context is loaded. +type ProjectContextMsg struct { + Owner string + Repo string + Error error +} + +func (m ProjectContextMsg) String() string { + if m.Error != nil { + return fmt.Sprintf("No project (%v)", m.Error) + } + return m.Owner + "/" + m.Repo +} + +// StoreEvent is a generic store event. +type StoreEvent struct { + Action string + Data interface{} +} + +func (e StoreEvent) String() string { + return fmt.Sprintf("[%s]", e.Action) +} diff --git a/tui/ui/components.go b/tui/ui/components.go new file mode 100644 index 0000000..c00e9ce --- /dev/null +++ b/tui/ui/components.go @@ -0,0 +1,84 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// TabDef defines a single tab. +type TabDef struct { + Key string + Label string +} + +// TabBarModel renders a horizontal tab bar. +type TabBarModel struct { + tabs []TabDef + active int +} + +func NewTabBar(tabs []TabDef) *TabBarModel { + return &TabBarModel{tabs: tabs} +} + +func (t *TabBarModel) View(active int) string { + var items []string + for i, tab := range t.tabs { + label := fmt.Sprintf("[%s] %s", tab.Key, tab.Label) + if i == active { + label = StyleSelected.Render(" " + label + " ") + } else { + label = StyleMuted.Render(label) + } + items = append(items, label) + } + return lipgloss.JoinHorizontal(lipgloss.Left, items...) +} + +// StatusBarModel renders a bottom status bar. +type StatusBarModel struct { + project string + message string + authStatus string +} + +func NewStatusBar() *StatusBarModel { + return &StatusBarModel{} +} + +func (s *StatusBarModel) SetProject(p string) { + s.project = p +} + +func (s *StatusBarModel) SetMessage(msg string) { + s.message = msg +} + +func (s *StatusBarModel) SetAuth(status string) { + s.authStatus = status +} + +func (s *StatusBarModel) View() string { + left := StyleMuted.Render(fmt.Sprintf(" Project: %s ", s.project)) + center := StyleCmd.Render(s.message) + right := StyleMuted.Render(fmt.Sprintf(" Ctrl+A AI | Ctrl+Space Chat | Ctrl+P Cmd | ? Help ")) + + width := 80 // will be overridden by layout + leftW := lipgloss.Width(left) + rightW := lipgloss.Width(right) + centerW := lipgloss.Width(center) + + gapLeft := (width - leftW - centerW - rightW) / 2 + if gapLeft < 0 { + gapLeft = 0 + } + gapRight := width - leftW - centerW - rightW - gapLeft + if gapRight < 0 { + gapRight = 0 + } + + bg := lipgloss.NewStyle().Background(lipgloss.Color("#313244")).Width(width) + return bg.Render(left + strings.Repeat(" ", gapLeft) + center + strings.Repeat(" ", gapRight) + right) +} diff --git a/tui/ui/keymap.go b/tui/ui/keymap.go new file mode 100644 index 0000000..ebc02b7 --- /dev/null +++ b/tui/ui/keymap.go @@ -0,0 +1,42 @@ +package ui + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap holds all global keybindings. +type KeyMap struct { + Quit key.Binding + Help key.Binding + AI key.Binding + AIPanel key.Binding + Commands key.Binding + Refresh key.Binding + + Up key.Binding + Down key.Binding + Back key.Binding + Enter key.Binding + + Create key.Binding + Delete key.Binding + Edit key.Binding + Filter key.Binding +} + +var DefaultKeyMap = KeyMap{ + Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("Ctrl+C", "Quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "Help")), + AI: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("Ctrl+A", "AI actions")), + AIPanel: key.NewBinding(key.WithKeys("ctrl+space"), key.WithHelp("Ctrl+Space", "AI Panel")), + Commands: key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("Ctrl+P", "Commands")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "Refresh")), + + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("j/k", "Navigate")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("", "")), + Back: key.NewBinding(key.WithKeys("esc", "h"), key.WithHelp("Esc", "Back")), + Enter: key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("Enter", "Select")), + + Create: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "Create")), + Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "Delete")), + Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "Edit")), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "Filter")), +} diff --git a/tui/ui/styles.go b/tui/ui/styles.go new file mode 100644 index 0000000..49635e3 --- /dev/null +++ b/tui/ui/styles.go @@ -0,0 +1,33 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +// Common styles used across components. +var ( + StyleTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(DefaultTheme.Primary) + + StyleMuted = lipgloss.NewStyle(). + Foreground(DefaultTheme.Muted) + + StyleError = lipgloss.NewStyle(). + Foreground(DefaultTheme.Error) + + StyleSuccess = lipgloss.NewStyle(). + Foreground(DefaultTheme.Success) + + StyleWarning = lipgloss.NewStyle(). + Foreground(DefaultTheme.Warning) + + StyleAccent = lipgloss.NewStyle(). + Foreground(DefaultTheme.Accent) + + StyleBorder = lipgloss.RoundedBorder() + + StyleSelected = lipgloss.NewStyle(). + Background(lipgloss.Color("#45475A")) + + StyleCmd = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A6E3A1")) +) diff --git a/tui/ui/theme.go b/tui/ui/theme.go new file mode 100644 index 0000000..2008ee2 --- /dev/null +++ b/tui/ui/theme.go @@ -0,0 +1,28 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +// Theme defines the color palette. +type Theme struct { + Primary lipgloss.Color + Success lipgloss.Color + Warning lipgloss.Color + Error lipgloss.Color + Muted lipgloss.Color + Background lipgloss.Color + Foreground lipgloss.Color + Border lipgloss.Color + Accent lipgloss.Color +} + +var DefaultTheme = Theme{ + Primary: lipgloss.Color("#6C8EBF"), + Success: lipgloss.Color("#82B366"), + Warning: lipgloss.Color("#D6B656"), + Error: lipgloss.Color("#B85450"), + Muted: lipgloss.Color("#666666"), + Background: lipgloss.Color("#1E1E2E"), + Foreground: lipgloss.Color("#CDD6F4"), + Border: lipgloss.Color("#45475A"), + Accent: lipgloss.Color("#89B4FA"), +} diff --git a/tui/views/home.go b/tui/views/home.go new file mode 100644 index 0000000..0f64f6e --- /dev/null +++ b/tui/views/home.go @@ -0,0 +1,141 @@ +package views + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/gitlink-org/gitlink-cli/tui/store" + "github.com/gitlink-org/gitlink-cli/tui/ui" +) + +// HomeModel is the dashboard view. +type HomeModel struct { + store *store.Store + width int + height int + + // Data + project string + authStatus string + issuesCount string + prsCount string + ciStatus string + releaseVer string + + // Onboarding + firstRun bool + onboardIdx int +} + +func NewHomeModel(s *store.Store) *HomeModel { + return &HomeModel{ + store: s, + firstRun: true, + } +} + +func (m *HomeModel) Init() tea.Cmd { + return tea.Batch( + m.store.LoadAuthStatus, + m.store.LoadProjectContext, + ) +} + +func (m *HomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case store.AuthStatusMsg: + if msg.LoggedIn { + m.authStatus = "Logged in" + } else { + m.authStatus = "Not logged in (run gitlink-cli auth login)" + } + + case store.ProjectContextMsg: + if msg.Error != nil { + m.project = "No GitLink project detected" + } else { + m.project = msg.Owner + "/" + msg.Repo + } + + case tea.KeyMsg: + switch msg.String() { + case "enter": + if m.firstRun { + m.firstRun = false + } + } + } + + return m, nil +} + +func (m *HomeModel) View() string { + if m.firstRun { + return m.onboardingView() + } + return m.dashboardView() +} + +func (m *HomeModel) dashboardView() string { + title := ui.StyleTitle.Render("šŸ  Home Dashboard") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + fmt.Sprintf(" Project: %s", ui.StyleAccent.Render(m.project)), + fmt.Sprintf(" Auth: %s", m.authStatus), + "", + ui.StyleMuted.Render(" Quick start:"), + fmt.Sprintf(" %s Switch views (1-7)", ui.StyleCmd.Render("[1]-[7]")), + fmt.Sprintf(" %s AI actions (context-aware)", ui.StyleCmd.Render("Ctrl+A")), + fmt.Sprintf(" %s Open AI chat panel", ui.StyleCmd.Render("Ctrl+Space")), + fmt.Sprintf(" %s Command palette", ui.StyleCmd.Render("Ctrl+P")), + fmt.Sprintf(" %s Help", ui.StyleCmd.Render("?")), + ) + + return lipgloss.NewStyle(). + Padding(1, 2). + Render(content) +} + +func (m *HomeModel) onboardingView() string { + welcome := lipgloss.NewStyle(). + Bold(true). + Foreground(ui.DefaultTheme.Primary). + Render("Welcome to GitLink TUI") + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.DefaultTheme.Primary). + Padding(1, 2). + Width(60) + + content := lipgloss.JoinVertical( + lipgloss.Left, + welcome, + "", + lipgloss.NewStyle().Bold(true).Render("One key to remember:"), + "", + fmt.Sprintf(" %s → AI actions, anywhere, context-aware", ui.StyleCmd.Render("Ctrl+A")), + "", + lipgloss.NewStyle().Bold(true).Render("Also useful:"), + fmt.Sprintf(" %s AI Chat panel", ui.StyleCmd.Render("Ctrl+Space")), + fmt.Sprintf(" %s Command palette", ui.StyleCmd.Render("Ctrl+P")), + fmt.Sprintf(" %s Switch views", ui.StyleCmd.Render("[1]-[7]")), + fmt.Sprintf(" %s Help", ui.StyleCmd.Render("?")), + "", + fmt.Sprintf(" Project: %s", ui.StyleAccent.Render(m.project)), + fmt.Sprintf(" Auth: %s", m.authStatus), + "", + ui.StyleCmd.Render(" [Enter] Got it!"), + ) + + return box.Render(content) +}