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