1package tui
2
3import (
4 "fmt"
5 "smoothie/git"
6 "smoothie/tui/bubbles/commits"
7 "smoothie/tui/bubbles/repo"
8 "smoothie/tui/bubbles/selection"
9 "strings"
10
11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/charmbracelet/lipgloss"
13 "github.com/gliderlabs/ssh"
14)
15
16type sessionState int
17
18const (
19 startState sessionState = iota
20 errorState
21 loadedState
22 quittingState
23 quitState
24)
25
26type Config struct {
27 Name string `json:"name"`
28 Host string `json:"host"`
29 Port int64 `json:"port"`
30 ShowAllRepos bool `json:"show_all_repos"`
31 Menu []MenuEntry `json:"menu"`
32 RepoSource *git.RepoSource
33}
34
35type MenuEntry struct {
36 Name string `json:"name"`
37 Note string `json:"note"`
38 Repo string `json:"repo"`
39 bubble *repo.Bubble
40}
41
42type SessionConfig struct {
43 Width int
44 Height int
45 WindowChanges <-chan ssh.Window
46 InitialRepo string
47}
48
49type Bubble struct {
50 config *Config
51 state sessionState
52 error string
53 width int
54 height int
55 windowChanges <-chan ssh.Window
56 repoSource *git.RepoSource
57 initialRepo string
58 repoMenu []MenuEntry
59 repos []*git.Repo
60 boxes []tea.Model
61 activeBox int
62 repoSelect *selection.Bubble
63 commitsLog *commits.Bubble
64}
65
66func NewBubble(cfg *Config, sCfg *SessionConfig) *Bubble {
67 b := &Bubble{
68 config: cfg,
69 width: sCfg.Width,
70 height: sCfg.Height,
71 windowChanges: sCfg.WindowChanges,
72 repoSource: cfg.RepoSource,
73 repoMenu: make([]MenuEntry, 0),
74 boxes: make([]tea.Model, 2),
75 initialRepo: sCfg.InitialRepo,
76 }
77 b.state = startState
78 return b
79}
80
81func (b *Bubble) Init() tea.Cmd {
82 return tea.Batch(b.windowChangesCmd, b.setupCmd)
83}
84
85func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86 cmds := make([]tea.Cmd, 0)
87 // Always allow state, error, info, window resize and quit messages
88 switch msg := msg.(type) {
89 case tea.KeyMsg:
90 switch msg.String() {
91 case "q", "ctrl+c":
92 return b, tea.Quit
93 case "tab", "shift+tab":
94 b.activeBox = (b.activeBox + 1) % 2
95 case "h", "left":
96 if b.activeBox > 0 {
97 b.activeBox--
98 }
99 case "l", "right":
100 if b.activeBox < len(b.boxes)-1 {
101 b.activeBox++
102 }
103 }
104 case errMsg:
105 b.error = msg.Error()
106 b.state = errorState
107 return b, nil
108 case windowMsg:
109 cmds = append(cmds, b.windowChangesCmd)
110 case tea.WindowSizeMsg:
111 b.width = msg.Width
112 b.height = msg.Height
113 if b.state == loadedState {
114 ab, cmd := b.boxes[b.activeBox].Update(msg)
115 b.boxes[b.activeBox] = ab
116 if cmd != nil {
117 cmds = append(cmds, cmd)
118 }
119 }
120 // XXX: maybe propagate size changes to child bubbles (particularly height)
121 case selection.SelectedMsg:
122 b.activeBox = 1
123 rb := b.repoMenu[msg.Index].bubble
124 rb.GotoTop()
125 b.boxes[1] = rb
126 case selection.ActiveMsg:
127 rb := b.repoMenu[msg.Index].bubble
128 rb.GotoTop()
129 b.boxes[1] = b.repoMenu[msg.Index].bubble
130 }
131 if b.state == loadedState {
132 ab, cmd := b.boxes[b.activeBox].Update(msg)
133 b.boxes[b.activeBox] = ab
134 if cmd != nil {
135 cmds = append(cmds, cmd)
136 }
137 }
138 return b, tea.Batch(cmds...)
139}
140
141func (b *Bubble) viewForBox(i int) string {
142 box := b.boxes[i]
143 isActive := i == b.activeBox
144 var s lipgloss.Style
145 var menuHeightFix int // TODO: figure out why we need this
146 switch box.(type) {
147 case *selection.Bubble:
148 menuHeightFix = 1
149 if isActive {
150 s = menuActiveStyle
151 break
152 }
153 s = menuStyle
154 case *repo.Bubble:
155 if isActive {
156 s = contentBoxActiveStyle
157 } else {
158 s = contentBoxStyle
159 }
160 const repoWidthFix = 1 // TODO: figure out why we need this
161 w := b.width -
162 lipgloss.Width(b.viewForBox(0)) -
163 appBoxStyle.GetHorizontalFrameSize() -
164 s.GetHorizontalFrameSize() + repoWidthFix
165 s = s.Copy().Width(w)
166 default:
167 panic(fmt.Sprintf("unknown box type %T", box))
168 }
169 h := b.height -
170 lipgloss.Height(b.headerView()) -
171 lipgloss.Height(b.footerView()) -
172 s.GetVerticalFrameSize() -
173 appBoxStyle.GetVerticalFrameSize() +
174 menuHeightFix
175 return s.Copy().Height(h).Render(box.View())
176}
177
178func (b Bubble) headerView() string {
179 w := b.width - appBoxStyle.GetHorizontalFrameSize()
180 return headerStyle.Copy().Width(w).Render(b.config.Name)
181}
182
183func (b Bubble) footerView() string {
184 w := &strings.Builder{}
185 var h []helpEntry
186 switch b.state {
187 case errorState:
188 h = []helpEntry{{"q", "quit"}}
189 default:
190 h = []helpEntry{
191 {"tab", "section"},
192 {"↑/↓", "navigate"},
193 {"q", "quit"},
194 }
195 if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
196 h = append(h[:2], helpEntry{"f/b", "pgup/pgdown"}, h[2])
197 }
198 }
199 for i, v := range h {
200 fmt.Fprint(w, v)
201 if i != len(h)-1 {
202 fmt.Fprint(w, helpDivider)
203 }
204 }
205 return footerStyle.Render(w.String())
206}
207
208func (b Bubble) errorView() string {
209 s := lipgloss.JoinHorizontal(
210 lipgloss.Top,
211 errorHeaderStyle.Render("Bummer"),
212 errorBodyStyle.Render(b.error),
213 )
214 h := b.height -
215 appBoxStyle.GetVerticalFrameSize() -
216 lipgloss.Height(b.headerView()) -
217 lipgloss.Height(b.footerView()) -
218 contentBoxStyle.GetVerticalFrameSize() +
219 3 // TODO: figure out why we need this
220 return errorStyle.Copy().Height(h).Render(s)
221}
222
223func (b Bubble) View() string {
224 s := strings.Builder{}
225 s.WriteString(b.headerView())
226 s.WriteRune('\n')
227 switch b.state {
228 case loadedState:
229 lb := b.viewForBox(0)
230 rb := b.viewForBox(1)
231 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
232 case errorState:
233 s.WriteString(b.errorView())
234 }
235 s.WriteString(b.footerView())
236 return appBoxStyle.Render(s.String())
237}
238
239type helpEntry struct {
240 key string
241 val string
242}
243
244func (h helpEntry) String() string {
245 return fmt.Sprintf("%s %s", helpKeyStyle.Render(h.key), helpValueStyle.Render(h.val))
246}