1package tui
2
3import (
4 "fmt"
5 "soft-serve/git"
6 "soft-serve/tui/bubbles/repo"
7 "soft-serve/tui/bubbles/selection"
8 "soft-serve/tui/style"
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 InitialRepo string
46}
47
48type Bubble struct {
49 config *Config
50 styles *style.Styles
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}
64
65func NewBubble(cfg *Config, sCfg *SessionConfig) *Bubble {
66 b := &Bubble{
67 config: cfg,
68 styles: style.DefaultStyles(),
69 width: sCfg.Width,
70 height: sCfg.Height,
71 repoSource: cfg.RepoSource,
72 repoMenu: make([]MenuEntry, 0),
73 boxes: make([]tea.Model, 2),
74 initialRepo: sCfg.InitialRepo,
75 }
76 b.state = startState
77 return b
78}
79
80func (b *Bubble) Init() tea.Cmd {
81 return b.setupCmd
82}
83
84func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85 cmds := make([]tea.Cmd, 0)
86 // Always allow state, error, info, window resize and quit messages
87 switch msg := msg.(type) {
88 case tea.KeyMsg:
89 switch msg.String() {
90 case "q", "ctrl+c":
91 return b, tea.Quit
92 case "tab", "shift+tab":
93 b.activeBox = (b.activeBox + 1) % 2
94 case "h", "left":
95 if b.activeBox > 0 {
96 b.activeBox--
97 }
98 case "l", "right":
99 if b.activeBox < len(b.boxes)-1 {
100 b.activeBox++
101 }
102 }
103 case errMsg:
104 b.error = msg.Error()
105 b.state = errorState
106 return b, nil
107 case tea.WindowSizeMsg:
108 b.width = msg.Width
109 b.height = msg.Height
110 if b.state == loadedState {
111 for i, bx := range b.boxes {
112 m, cmd := bx.Update(msg)
113 b.boxes[i] = m
114 if cmd != nil {
115 cmds = append(cmds, cmd)
116 }
117 }
118 }
119 case selection.SelectedMsg:
120 b.activeBox = 1
121 rb := b.repoMenu[msg.Index].bubble
122 rb.GotoTop()
123 b.boxes[1] = rb
124 case selection.ActiveMsg:
125 rb := b.repoMenu[msg.Index].bubble
126 rb.GotoTop()
127 b.boxes[1] = b.repoMenu[msg.Index].bubble
128 }
129 if b.state == loadedState {
130 ab, cmd := b.boxes[b.activeBox].Update(msg)
131 b.boxes[b.activeBox] = ab
132 if cmd != nil {
133 cmds = append(cmds, cmd)
134 }
135 }
136 return b, tea.Batch(cmds...)
137}
138
139func (b *Bubble) viewForBox(i int) string {
140 isActive := i == b.activeBox
141 switch box := b.boxes[i].(type) {
142 case *selection.Bubble:
143 // Menu
144 var s lipgloss.Style
145 s = b.styles.Menu
146 if isActive {
147 s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
148 }
149 return s.Render(box.View())
150 case *repo.Bubble:
151 // Repo details
152 box.Active = isActive
153 return box.View()
154 default:
155 panic(fmt.Sprintf("unknown box type %T", box))
156 }
157}
158
159func (b Bubble) headerView() string {
160 w := b.width - b.styles.App.GetHorizontalFrameSize()
161 return b.styles.Header.Copy().Width(w).Render(b.config.Name)
162}
163
164func (b Bubble) footerView() string {
165 w := &strings.Builder{}
166 var h []helpEntry
167 switch b.state {
168 case errorState:
169 h = []helpEntry{{"q", "quit"}}
170 default:
171 h = []helpEntry{
172 {"tab", "section"},
173 {"↑/↓", "navigate"},
174 {"q", "quit"},
175 }
176 if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
177 h = append(h[:2], helpEntry{"f/b", "pgup/pgdown"}, h[2])
178 }
179 }
180 for i, v := range h {
181 fmt.Fprint(w, v.Render(b.styles))
182 if i != len(h)-1 {
183 fmt.Fprint(w, b.styles.HelpDivider)
184 }
185 }
186 return b.styles.Footer.Copy().Width(b.width).Render(w.String())
187}
188
189func (b Bubble) errorView() string {
190 s := b.styles
191 str := lipgloss.JoinHorizontal(
192 lipgloss.Top,
193 s.ErrorTitle.Render("Bummer"),
194 s.ErrorBody.Render(b.error),
195 )
196 h := b.height -
197 s.App.GetVerticalFrameSize() -
198 lipgloss.Height(b.headerView()) -
199 lipgloss.Height(b.footerView()) -
200 s.RepoBody.GetVerticalFrameSize() +
201 3 // TODO: this is repo header height -- get it dynamically
202 return s.Error.Copy().Height(h).Render(str)
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 b.styles.App.Render(s.String())
220}
221
222type helpEntry struct {
223 key string
224 val string
225}
226
227func (h helpEntry) Render(s *style.Styles) string {
228 return fmt.Sprintf("%s %s", s.HelpKey.Render(h.key), s.HelpValue.Render(h.val))
229}