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