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/internal/config"
10 "github.com/charmbracelet/soft/internal/tui/bubbles/repo"
11 "github.com/charmbracelet/soft/internal/tui/bubbles/selection"
12 "github.com/charmbracelet/soft/internal/tui/style"
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 SessionConfig struct {
27 Width int
28 Height int
29 InitialRepo string
30 Session ssh.Session
31}
32
33type MenuEntry struct {
34 Name string `json:"name"`
35 Note string `json:"note"`
36 Repo string `json:"repo"`
37 bubble *repo.Bubble
38}
39
40type Bubble struct {
41 config *config.Config
42 styles *style.Styles
43 state sessionState
44 error string
45 width int
46 height int
47 initialRepo string
48 repoMenu []MenuEntry
49 boxes []tea.Model
50 activeBox int
51 repoSelect *selection.Bubble
52 session ssh.Session
53
54 // remember the last resize so we can re-send it when selecting a different repo.
55 lastResize tea.WindowSizeMsg
56}
57
58func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
59 b := &Bubble{
60 config: cfg,
61 styles: style.DefaultStyles(),
62 width: sCfg.Width,
63 height: sCfg.Height,
64 repoMenu: make([]MenuEntry, 0),
65 boxes: make([]tea.Model, 2),
66 initialRepo: sCfg.InitialRepo,
67 session: sCfg.Session,
68 }
69 b.state = startState
70 return b
71}
72
73func (b *Bubble) Init() tea.Cmd {
74 return b.setupCmd
75}
76
77func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78 cmds := make([]tea.Cmd, 0)
79 switch msg := msg.(type) {
80 case tea.KeyMsg:
81 switch msg.String() {
82 case "q", "ctrl+c":
83 return b, tea.Quit
84 case "tab", "shift+tab":
85 b.activeBox = (b.activeBox + 1) % 2
86 case "h", "left":
87 if b.activeBox > 0 {
88 b.activeBox--
89 }
90 case "l", "right":
91 if b.activeBox < len(b.boxes)-1 {
92 b.activeBox++
93 }
94 }
95 case errMsg:
96 b.error = msg.Error()
97 b.state = errorState
98 return b, nil
99 case tea.WindowSizeMsg:
100 b.lastResize = msg
101 b.width = msg.Width
102 b.height = msg.Height
103 if b.state == loadedState {
104 for i, bx := range b.boxes {
105 m, cmd := bx.Update(msg)
106 b.boxes[i] = m
107 if cmd != nil {
108 cmds = append(cmds, cmd)
109 }
110 }
111 }
112 case selection.SelectedMsg:
113 b.activeBox = 1
114 rb := b.repoMenu[msg.Index].bubble
115 rb.GotoTop()
116 b.boxes[1] = rb
117 case selection.ActiveMsg:
118 rb := b.repoMenu[msg.Index].bubble
119 rb.GotoTop()
120 b.boxes[1] = b.repoMenu[msg.Index].bubble
121 cmds = append(cmds, func() tea.Msg {
122 return b.lastResize
123 })
124 }
125 if b.state == loadedState {
126 ab, cmd := b.boxes[b.activeBox].Update(msg)
127 b.boxes[b.activeBox] = ab
128 if cmd != nil {
129 cmds = append(cmds, cmd)
130 }
131 }
132 return b, tea.Batch(cmds...)
133}
134
135func (b *Bubble) viewForBox(i int) string {
136 isActive := i == b.activeBox
137 switch box := b.boxes[i].(type) {
138 case *selection.Bubble:
139 // Menu
140 var s lipgloss.Style
141 s = b.styles.Menu
142 if isActive {
143 s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
144 }
145 return s.Render(box.View())
146 case *repo.Bubble:
147 // Repo details
148 box.Active = isActive
149 return box.View()
150 default:
151 panic(fmt.Sprintf("unknown box type %T", box))
152 }
153}
154
155func (b Bubble) headerView() string {
156 w := b.width - b.styles.App.GetHorizontalFrameSize()
157 name := ""
158 if b.config != nil {
159 name = b.config.Name
160 }
161 return b.styles.Header.Copy().Width(w).Render(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", "pgdown/pgup"}, 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}