1package tui
2
3import (
4 "fmt"
5 "strings"
6
7 tea "github.com/charmbracelet/bubbletea"
8 "github.com/charmbracelet/lipgloss"
9 "github.com/charmbracelet/soft-serve/internal/config"
10 gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
11 "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
12 "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
13 "github.com/charmbracelet/soft-serve/internal/tui/style"
14 "github.com/gliderlabs/ssh"
15)
16
17const (
18 repoNameMaxWidth = 32
19)
20
21type sessionState int
22
23const (
24 startState sessionState = iota
25 errorState
26 loadedState
27 quittingState
28 quitState
29)
30
31type SessionConfig struct {
32 Width int
33 Height int
34 InitialRepo string
35 Session ssh.Session
36}
37
38type MenuEntry struct {
39 Name string `json:"name"`
40 Note string `json:"note"`
41 Repo string `json:"repo"`
42 bubble *repo.Bubble
43}
44
45type Bubble struct {
46 config *config.Config
47 styles *style.Styles
48 state sessionState
49 error string
50 width int
51 height int
52 initialRepo string
53 repoMenu []MenuEntry
54 boxes []tea.Model
55 activeBox int
56 repoSelect *selection.Bubble
57 session ssh.Session
58
59 // remember the last resize so we can re-send it when selecting a different repo.
60 lastResize tea.WindowSizeMsg
61}
62
63func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
64 b := &Bubble{
65 config: cfg,
66 styles: style.DefaultStyles(),
67 width: sCfg.Width,
68 height: sCfg.Height,
69 repoMenu: make([]MenuEntry, 0),
70 boxes: make([]tea.Model, 2),
71 initialRepo: sCfg.InitialRepo,
72 session: sCfg.Session,
73 }
74 b.state = startState
75 return b
76}
77
78func (b *Bubble) Init() tea.Cmd {
79 return b.setupCmd
80}
81
82func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
83 cmds := make([]tea.Cmd, 0)
84 switch msg := msg.(type) {
85 case tea.KeyMsg:
86 switch msg.String() {
87 case "q", "ctrl+c":
88 return b, tea.Quit
89 case "tab", "shift+tab":
90 b.activeBox = (b.activeBox + 1) % 2
91 }
92 case errMsg:
93 b.error = msg.Error()
94 b.state = errorState
95 return b, nil
96 case tea.WindowSizeMsg:
97 b.lastResize = msg
98 b.width = msg.Width
99 b.height = msg.Height
100 if b.state == loadedState {
101 for i, bx := range b.boxes {
102 m, cmd := bx.Update(msg)
103 b.boxes[i] = m
104 if cmd != nil {
105 cmds = append(cmds, cmd)
106 }
107 }
108 }
109 case selection.SelectedMsg:
110 b.activeBox = 1
111 rb := b.repoMenu[msg.Index].bubble
112 b.boxes[1] = rb
113 case selection.ActiveMsg:
114 b.boxes[1] = b.repoMenu[msg.Index].bubble
115 cmds = append(cmds, func() tea.Msg {
116 return b.lastResize
117 })
118 }
119 if b.state == loadedState {
120 ab, cmd := b.boxes[b.activeBox].Update(msg)
121 b.boxes[b.activeBox] = ab
122 if cmd != nil {
123 cmds = append(cmds, cmd)
124 }
125 }
126 return b, tea.Batch(cmds...)
127}
128
129func (b *Bubble) viewForBox(i int) string {
130 isActive := i == b.activeBox
131 switch box := b.boxes[i].(type) {
132 case *selection.Bubble:
133 // Menu
134 var s lipgloss.Style
135 s = b.styles.Menu
136 if isActive {
137 s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
138 }
139 return s.Render(box.View())
140 case *repo.Bubble:
141 // Repo details
142 box.Active = isActive
143 return box.View()
144 default:
145 panic(fmt.Sprintf("unknown box type %T", box))
146 }
147}
148
149func (b Bubble) headerView() string {
150 w := b.width - b.styles.App.GetHorizontalFrameSize()
151 name := ""
152 if b.config != nil {
153 name = b.config.Name
154 }
155 return b.styles.Header.Copy().Width(w).Render(name)
156}
157
158func (b Bubble) footerView() string {
159 w := &strings.Builder{}
160 var h []gittypes.HelpEntry
161 if b.state != errorState {
162 h = []gittypes.HelpEntry{
163 {"tab", "section"},
164 }
165 if box, ok := b.boxes[b.activeBox].(gittypes.HelpableBubble); ok {
166 help := box.Help()
167 for _, he := range help {
168 h = append(h, he)
169 }
170 }
171 }
172 h = append(h, gittypes.HelpEntry{"q", "quit"})
173 for i, v := range h {
174 fmt.Fprint(w, helpEntryRender(v, b.styles))
175 if i != len(h)-1 {
176 fmt.Fprint(w, b.styles.HelpDivider)
177 }
178 }
179 branch := ""
180 if b.state == loadedState {
181 branch = b.boxes[1].(*repo.Bubble).Reference().Short()
182 }
183 help := w.String()
184 branchMaxWidth := b.width - // bubble width
185 lipgloss.Width(help) - // help width
186 b.styles.App.GetHorizontalFrameSize() // App paddings
187 branch = b.styles.Branch.Render(gittypes.TruncateString(branch, branchMaxWidth-1, "…"))
188 gap := lipgloss.NewStyle().
189 Width(b.width -
190 lipgloss.Width(help) -
191 lipgloss.Width(branch) -
192 b.styles.App.GetHorizontalFrameSize()).
193 Render("")
194 footer := lipgloss.JoinHorizontal(lipgloss.Top, help, gap, branch)
195 return b.styles.Footer.Render(footer)
196}
197
198func (b Bubble) errorView() string {
199 s := b.styles
200 str := lipgloss.JoinHorizontal(
201 lipgloss.Top,
202 s.ErrorTitle.Render("Bummer"),
203 s.ErrorBody.Render(b.error),
204 )
205 h := b.height -
206 s.App.GetVerticalFrameSize() -
207 lipgloss.Height(b.headerView()) -
208 lipgloss.Height(b.footerView()) -
209 s.RepoBody.GetVerticalFrameSize() +
210 3 // TODO: this is repo header height -- get it dynamically
211 return s.Error.Copy().Height(h).Render(str)
212}
213
214func (b Bubble) View() string {
215 s := strings.Builder{}
216 s.WriteString(b.headerView())
217 s.WriteRune('\n')
218 switch b.state {
219 case loadedState:
220 lb := b.viewForBox(0)
221 rb := b.viewForBox(1)
222 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
223 case errorState:
224 s.WriteString(b.errorView())
225 }
226 s.WriteRune('\n')
227 s.WriteString(b.footerView())
228 return b.styles.App.Render(s.String())
229}
230
231func helpEntryRender(h gittypes.HelpEntry, s *style.Styles) string {
232 return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value))
233}