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