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