1package repo
2
3import (
4 "fmt"
5
6 "github.com/charmbracelet/bubbles/help"
7 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/spinner"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11 "github.com/charmbracelet/soft-serve/config"
12 ggit "github.com/charmbracelet/soft-serve/git"
13 "github.com/charmbracelet/soft-serve/ui/common"
14 "github.com/charmbracelet/soft-serve/ui/components/statusbar"
15 "github.com/charmbracelet/soft-serve/ui/components/tabs"
16 "github.com/charmbracelet/soft-serve/ui/git"
17)
18
19type tab int
20
21const (
22 readmeTab tab = iota
23 filesTab
24 commitsTab
25 branchesTab
26 tagsTab
27 lastTab
28)
29
30func (t tab) String() string {
31 return []string{
32 "Readme",
33 "Files",
34 "Commits",
35 "Branches",
36 "Tags",
37 }[t]
38}
39
40// UpdateStatusBarMsg updates the status bar.
41type UpdateStatusBarMsg struct{}
42
43// RepoMsg is a message that contains a git.Repository.
44type RepoMsg git.GitRepo
45
46// RefMsg is a message that contains a git.Reference.
47type RefMsg *ggit.Reference
48
49// Repo is a view for a git repository.
50type Repo struct {
51 common common.Common
52 cfg *config.Config
53 selectedRepo git.GitRepo
54 activeTab tab
55 tabs *tabs.Tabs
56 statusbar *statusbar.StatusBar
57 boxes []common.Component
58 ref *ggit.Reference
59 BackKey key.Binding
60 TabKey key.Binding
61}
62
63// New returns a new Repo.
64func New(cfg *config.Config, c common.Common) *Repo {
65 sb := statusbar.New(c)
66 ts := make([]string, lastTab)
67 // Tabs must match the order of tab constants above.
68 for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
69 ts[i] = t.String()
70 }
71 tb := tabs.New(c, ts)
72 readme := NewReadme(c)
73 log := NewLog(c)
74 files := NewFiles(c)
75 branches := NewRefs(c, ggit.RefsHeads)
76 tags := NewRefs(c, ggit.RefsTags)
77 // Make sure the order matches the order of tab constants above.
78 boxes := []common.Component{
79 readme,
80 files,
81 log,
82 branches,
83 tags,
84 }
85 back := c.KeyMap.Back
86 back.SetHelp("esc", "back to menu")
87 tab := c.KeyMap.Section
88 tab.SetHelp("tab", "switch tab")
89 r := &Repo{
90 cfg: cfg,
91 common: c,
92 tabs: tb,
93 statusbar: sb,
94 boxes: boxes,
95 BackKey: back,
96 TabKey: tab,
97 }
98 return r
99}
100
101// SetSize implements common.Component.
102func (r *Repo) SetSize(width, height int) {
103 r.common.SetSize(width, height)
104 hm := r.common.Styles.RepoBody.GetVerticalFrameSize() +
105 r.common.Styles.RepoHeader.GetHeight() +
106 r.common.Styles.RepoHeader.GetVerticalFrameSize() +
107 r.common.Styles.StatusBar.GetHeight() +
108 r.common.Styles.Tabs.GetHeight() +
109 r.common.Styles.Tabs.GetVerticalFrameSize()
110 r.tabs.SetSize(width, height-hm)
111 r.statusbar.SetSize(width, height-hm)
112 for _, b := range r.boxes {
113 b.SetSize(width, height-hm)
114 }
115}
116
117func (r *Repo) commonHelp() []key.Binding {
118 b := make([]key.Binding, 0)
119 b = append(b, r.BackKey)
120 b = append(b, r.TabKey)
121 return b
122}
123
124// ShortHelp implements help.KeyMap.
125func (r *Repo) ShortHelp() []key.Binding {
126 b := r.commonHelp()
127 b = append(b, r.boxes[r.activeTab].(help.KeyMap).ShortHelp()...)
128 return b
129}
130
131// FullHelp implements help.KeyMap.
132func (r *Repo) FullHelp() [][]key.Binding {
133 b := make([][]key.Binding, 0)
134 b = append(b, r.commonHelp())
135 b = append(b, r.boxes[r.activeTab].(help.KeyMap).FullHelp()...)
136 return b
137}
138
139// Init implements tea.View.
140func (r *Repo) Init() tea.Cmd {
141 return tea.Batch(
142 r.tabs.Init(),
143 r.statusbar.Init(),
144 )
145}
146
147// Update implements tea.Model.
148func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149 cmds := make([]tea.Cmd, 0)
150 switch msg := msg.(type) {
151 case RepoMsg:
152 r.activeTab = 0
153 r.selectedRepo = git.GitRepo(msg)
154 cmds = append(cmds,
155 r.tabs.Init(),
156 r.updateRefCmd,
157 r.updateModels(msg),
158 )
159 case RefMsg:
160 r.ref = msg
161 for _, b := range r.boxes {
162 cmds = append(cmds, b.Init())
163 }
164 cmds = append(cmds,
165 r.updateStatusBarCmd,
166 r.updateModels(msg),
167 )
168 case tabs.SelectTabMsg:
169 r.activeTab = tab(msg)
170 t, cmd := r.tabs.Update(msg)
171 r.tabs = t.(*tabs.Tabs)
172 if cmd != nil {
173 cmds = append(cmds, cmd)
174 }
175 case tabs.ActiveTabMsg:
176 r.activeTab = tab(msg)
177 if r.selectedRepo != nil {
178 cmds = append(cmds,
179 r.updateStatusBarCmd,
180 )
181 }
182 case tea.KeyMsg, tea.MouseMsg:
183 t, cmd := r.tabs.Update(msg)
184 r.tabs = t.(*tabs.Tabs)
185 if cmd != nil {
186 cmds = append(cmds, cmd)
187 }
188 if r.selectedRepo != nil {
189 cmds = append(cmds, r.updateStatusBarCmd)
190 }
191 case ReadmeMsg:
192 case FileItemsMsg:
193 f, cmd := r.boxes[filesTab].Update(msg)
194 r.boxes[filesTab] = f.(*Files)
195 if cmd != nil {
196 cmds = append(cmds, cmd)
197 }
198 // The Log bubble is the only bubble that uses a spinner, so this is fine
199 // for now. We need to pass the TickMsg to the Log bubble when the Log is
200 // loading but not the current selected tab so that the spinner works.
201 case LogCountMsg, LogItemsMsg, spinner.TickMsg:
202 l, cmd := r.boxes[commitsTab].Update(msg)
203 r.boxes[commitsTab] = l.(*Log)
204 if cmd != nil {
205 cmds = append(cmds, cmd)
206 }
207 case RefItemsMsg:
208 switch msg.prefix {
209 case ggit.RefsHeads:
210 b, cmd := r.boxes[branchesTab].Update(msg)
211 r.boxes[branchesTab] = b.(*Refs)
212 if cmd != nil {
213 cmds = append(cmds, cmd)
214 }
215 case ggit.RefsTags:
216 t, cmd := r.boxes[tagsTab].Update(msg)
217 r.boxes[tagsTab] = t.(*Refs)
218 if cmd != nil {
219 cmds = append(cmds, cmd)
220 }
221 }
222 case UpdateStatusBarMsg:
223 cmds = append(cmds, r.updateStatusBarCmd)
224 case tea.WindowSizeMsg:
225 cmds = append(cmds, r.updateModels(msg))
226 }
227 s, cmd := r.statusbar.Update(msg)
228 r.statusbar = s.(*statusbar.StatusBar)
229 if cmd != nil {
230 cmds = append(cmds, cmd)
231 }
232 m, cmd := r.boxes[r.activeTab].Update(msg)
233 r.boxes[r.activeTab] = m.(common.Component)
234 if cmd != nil {
235 cmds = append(cmds, cmd)
236 }
237 return r, tea.Batch(cmds...)
238}
239
240// View implements tea.Model.
241func (r *Repo) View() string {
242 s := r.common.Styles.Repo.Copy().
243 Width(r.common.Width).
244 Height(r.common.Height)
245 repoBodyStyle := r.common.Styles.RepoBody.Copy()
246 hm := repoBodyStyle.GetVerticalFrameSize() +
247 r.common.Styles.RepoHeader.GetHeight() +
248 r.common.Styles.RepoHeader.GetVerticalFrameSize() +
249 r.common.Styles.StatusBar.GetHeight() +
250 r.common.Styles.Tabs.GetHeight() +
251 r.common.Styles.Tabs.GetVerticalFrameSize()
252 mainStyle := repoBodyStyle.
253 Height(r.common.Height - hm)
254 main := r.boxes[r.activeTab].View()
255 view := lipgloss.JoinVertical(lipgloss.Top,
256 r.headerView(),
257 r.tabs.View(),
258 mainStyle.Render(main),
259 r.statusbar.View(),
260 )
261 return s.Render(view)
262}
263
264func (r *Repo) headerView() string {
265 if r.selectedRepo == nil {
266 return ""
267 }
268 truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
269 name := r.common.Styles.RepoHeaderName.Render(r.selectedRepo.Name())
270 desc := r.selectedRepo.Description()
271 if desc == "" {
272 desc = name
273 name = ""
274 } else {
275 desc = r.common.Styles.RepoHeaderDesc.Render(desc)
276 }
277 if cfg := r.cfg; cfg != nil {
278 // TODO move this into a style.
279 urlStyle := lipgloss.NewStyle().
280 MarginLeft(1).
281 Foreground(lipgloss.Color("168")).
282 Width(r.common.Width - lipgloss.Width(desc) - 1).
283 Align(lipgloss.Right)
284 url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo())
285 url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
286 url = urlStyle.Render(url)
287 desc = lipgloss.JoinHorizontal(lipgloss.Left,
288 desc,
289 url,
290 )
291 }
292 style := r.common.Styles.RepoHeader.Copy().Width(r.common.Width)
293 return style.Render(
294 lipgloss.JoinVertical(lipgloss.Top,
295 truncate.Render(name),
296 truncate.Render(desc),
297 ),
298 )
299}
300
301func (r *Repo) updateStatusBarCmd() tea.Msg {
302 if r.selectedRepo == nil {
303 return nil
304 }
305 value := r.boxes[r.activeTab].(statusbar.Model).StatusBarValue()
306 info := r.boxes[r.activeTab].(statusbar.Model).StatusBarInfo()
307 ref := ""
308 if r.ref != nil {
309 ref = r.ref.Name().Short()
310 }
311 return statusbar.StatusBarMsg{
312 Key: r.selectedRepo.Repo(),
313 Value: value,
314 Info: info,
315 Branch: fmt.Sprintf("* %s", ref),
316 }
317}
318
319func (r *Repo) updateRefCmd() tea.Msg {
320 if r.selectedRepo == nil {
321 return nil
322 }
323 head, err := r.selectedRepo.HEAD()
324 if err != nil {
325 return common.ErrorMsg(err)
326 }
327 return RefMsg(head)
328}
329
330func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
331 cmds := make([]tea.Cmd, 0)
332 for i, b := range r.boxes {
333 m, cmd := b.Update(msg)
334 r.boxes[i] = m.(common.Component)
335 if cmd != nil {
336 cmds = append(cmds, cmd)
337 }
338 }
339 return tea.Batch(cmds...)
340}
341
342func updateStatusBarCmd() tea.Msg {
343 return UpdateStatusBarMsg{}
344}