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 switch box.(type) {
146 case *selection.Bubble:
147 if isActive {
148 s = menuActiveStyle
149 } else {
150 s = menuStyle
151 }
152 h := b.height -
153 lipgloss.Height(b.headerView()) -
154 lipgloss.Height(b.footerView()) -
155 s.GetVerticalFrameSize() -
156 appBoxStyle.GetVerticalFrameSize() +
157 1 // TODO: figure out why we need this
158 s = s.Copy().Height(h)
159 case *repo.Bubble:
160 if isActive {
161 s = contentBoxActiveStyle
162 } else {
163 s = contentBoxStyle
164 }
165 w := b.width -
166 lipgloss.Width(b.viewForBox(0)) -
167 appBoxStyle.GetHorizontalFrameSize() -
168 s.GetHorizontalFrameSize() +
169 1 // TODO: figure out why we need this
170 s = s.Copy().Width(w)
171 default:
172 panic(fmt.Sprintf("unknown box type %T", box))
173 }
174 return s.Render(box.View())
175}
176
177func (b Bubble) headerView() string {
178 w := b.width - appBoxStyle.GetHorizontalFrameSize()
179 return headerStyle.Copy().Width(w).Render(b.config.Name)
180}
181
182func (b Bubble) footerView() string {
183 w := &strings.Builder{}
184 var h []helpEntry
185 switch b.state {
186 case errorState:
187 h = []helpEntry{{"q", "quit"}}
188 default:
189 h = []helpEntry{
190 {"tab", "section"},
191 {"↑/↓", "navigate"},
192 {"q", "quit"},
193 }
194 if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
195 h = append(h[:2], helpEntry{"f/b", "pgup/pgdown"}, h[2])
196 }
197 }
198 for i, v := range h {
199 fmt.Fprint(w, v)
200 if i != len(h)-1 {
201 fmt.Fprint(w, helpDivider)
202 }
203 }
204 return footerStyle.Copy().Width(b.width).Render(w.String())
205}
206
207func (b Bubble) errorView() string {
208 s := lipgloss.JoinHorizontal(
209 lipgloss.Top,
210 errorHeaderStyle.Render("Bummer"),
211 errorBodyStyle.Render(b.error),
212 )
213 h := b.height -
214 appBoxStyle.GetVerticalFrameSize() -
215 lipgloss.Height(b.headerView()) -
216 lipgloss.Height(b.footerView()) -
217 contentBoxStyle.GetVerticalFrameSize() +
218 3 // TODO: figure out why we need this
219 return errorStyle.Copy().Height(h).Render(s)
220}
221
222func (b Bubble) View() string {
223 s := strings.Builder{}
224 s.WriteString(b.headerView())
225 s.WriteRune('\n')
226 switch b.state {
227 case loadedState:
228 lb := b.viewForBox(0)
229 rb := b.viewForBox(1)
230 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
231 case errorState:
232 s.WriteString(b.errorView())
233 }
234 s.WriteRune('\n')
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}