1package repo
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/soft-serve/git"
13 "github.com/charmbracelet/soft-serve/pkg/proto"
14 "github.com/charmbracelet/soft-serve/pkg/ui/common"
15 "github.com/charmbracelet/soft-serve/pkg/ui/components/footer"
16 "github.com/charmbracelet/soft-serve/pkg/ui/components/selector"
17 "github.com/charmbracelet/soft-serve/pkg/ui/components/statusbar"
18 "github.com/charmbracelet/soft-serve/pkg/ui/components/tabs"
19)
20
21type state int
22
23const (
24 loadingState state = iota
25 readyState
26)
27
28// EmptyRepoMsg is a message to indicate that the repository is empty.
29type EmptyRepoMsg struct{}
30
31// CopyURLMsg is a message to copy the URL of the current repository.
32type CopyURLMsg struct{}
33
34// RepoMsg is a message that contains a git.Repository.
35type RepoMsg proto.Repository //nolint:revive
36
37// GoBackMsg is a message to go back to the previous view.
38type GoBackMsg struct{}
39
40// CopyMsg is a message to indicate copied text.
41type CopyMsg struct {
42 Text string
43 Message string
44}
45
46// SwitchTabMsg is a message to switch tabs.
47type SwitchTabMsg common.TabComponent
48
49// Repo is a view for a git repository.
50type Repo struct {
51 common common.Common
52 selectedRepo proto.Repository
53 activeTab int
54 tabs *tabs.Tabs
55 statusbar *statusbar.Model
56 panes []common.TabComponent
57 ref *git.Reference
58 state state
59 spinner spinner.Model
60 panesReady []bool
61}
62
63// New returns a new Repo.
64func New(c common.Common, comps ...common.TabComponent) *Repo {
65 sb := statusbar.New(c)
66 ts := make([]string, 0)
67 for _, c := range comps {
68 ts = append(ts, c.TabName())
69 }
70 c.Logger = c.Logger.WithPrefix("ui.repo")
71 tb := tabs.New(c, ts)
72 // Make sure the order matches the order of tab constants above.
73 s := spinner.New(spinner.WithSpinner(spinner.Dot),
74 spinner.WithStyle(c.Styles.Spinner))
75 r := &Repo{
76 common: c,
77 tabs: tb,
78 statusbar: sb,
79 panes: comps,
80 state: loadingState,
81 spinner: s,
82 panesReady: make([]bool, len(comps)),
83 }
84 return r
85}
86
87func (r *Repo) getMargins() (int, int) {
88 hh := lipgloss.Height(r.headerView())
89 hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
90 hh +
91 r.common.Styles.Repo.Header.GetVerticalFrameSize() +
92 r.common.Styles.StatusBar.GetHeight()
93 return 0, hm
94}
95
96// SetSize implements common.Component.
97func (r *Repo) SetSize(width, height int) {
98 r.common.SetSize(width, height)
99 _, hm := r.getMargins()
100 r.tabs.SetSize(width, height-hm)
101 r.statusbar.SetSize(width, height-hm)
102 for _, p := range r.panes {
103 p.SetSize(width, height-hm)
104 }
105}
106
107// Path returns the current component path.
108func (r *Repo) Path() string {
109 return r.panes[r.activeTab].Path()
110}
111
112func (r *Repo) commonHelp() []key.Binding {
113 b := make([]key.Binding, 0)
114 back := r.common.KeyMap.Back
115 back.SetHelp("esc", "back to menu")
116 tab := r.common.KeyMap.Section
117 tab.SetHelp("tab", "switch tab")
118 b = append(b, back)
119 b = append(b, tab)
120 return b
121}
122
123// ShortHelp implements help.KeyMap.
124func (r *Repo) ShortHelp() []key.Binding {
125 b := r.commonHelp()
126 b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
127 return b
128}
129
130// FullHelp implements help.KeyMap.
131func (r *Repo) FullHelp() [][]key.Binding {
132 b := make([][]key.Binding, 0)
133 b = append(b, r.commonHelp())
134 b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
135 return b
136}
137
138// Init implements tea.View.
139func (r *Repo) Init() tea.Cmd {
140 r.state = loadingState
141 r.activeTab = 0
142 return tea.Batch(
143 r.tabs.Init(),
144 r.statusbar.Init(),
145 r.spinner.Tick,
146 )
147}
148
149// Update implements tea.Model.
150func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151 cmds := make([]tea.Cmd, 0)
152 switch msg := msg.(type) {
153 case RepoMsg:
154 // Set the state to loading when we get a new repository.
155 r.selectedRepo = msg
156 cmds = append(cmds,
157 r.Init(),
158 // This will set the selected repo in each pane's model.
159 r.updateModels(msg),
160 )
161 case RefMsg:
162 r.ref = msg
163 cmds = append(cmds, r.updateModels(msg))
164 r.state = readyState
165 case tabs.SelectTabMsg:
166 r.activeTab = int(msg)
167 t, cmd := r.tabs.Update(msg)
168 r.tabs = t.(*tabs.Tabs)
169 if cmd != nil {
170 cmds = append(cmds, cmd)
171 }
172 case tabs.ActiveTabMsg:
173 r.activeTab = int(msg)
174 case tea.KeyPressMsg, tea.MouseClickMsg:
175 t, cmd := r.tabs.Update(msg)
176 r.tabs = t.(*tabs.Tabs)
177 if cmd != nil {
178 cmds = append(cmds, cmd)
179 }
180 if r.selectedRepo != nil {
181 urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
182 cmd := r.common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())
183 if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
184 cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard"))
185 }
186 }
187 switch msg := msg.(type) {
188 case tea.MouseClickMsg:
189 switch msg.Button {
190 case tea.MouseLeft:
191 switch {
192 case r.common.Zone.Get("repo-help").InBounds(msg):
193 cmds = append(cmds, footer.ToggleFooterCmd)
194 }
195 case tea.MouseRight:
196 switch {
197 case r.common.Zone.Get("repo-main").InBounds(msg):
198 cmds = append(cmds, goBackCmd)
199 }
200 }
201 }
202 switch msg := msg.(type) {
203 case tea.KeyPressMsg:
204 switch {
205 case key.Matches(msg, r.common.KeyMap.Back):
206 cmds = append(cmds, goBackCmd)
207 }
208 }
209 case CopyMsg:
210 txt := msg.Text
211 if cfg := r.common.Config(); cfg != nil {
212 cmds = append(cmds, tea.SetClipboard(txt))
213 }
214 r.statusbar.SetStatus("", msg.Message, "", "")
215 case ReadmeMsg:
216 cmds = append(cmds, r.updateTabComponent(&Readme{}, msg))
217 case FileItemsMsg, FileContentMsg:
218 cmds = append(cmds, r.updateTabComponent(&Files{}, msg))
219 case LogItemsMsg, LogDiffMsg, LogCountMsg:
220 cmds = append(cmds, r.updateTabComponent(&Log{}, msg))
221 case RefItemsMsg:
222 cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))
223 case StashListMsg, StashPatchMsg:
224 cmds = append(cmds, r.updateTabComponent(&Stash{}, msg))
225 // We have two spinners, one is used to when loading the repository and the
226 // other is used when loading the log.
227 // Check if the spinner ID matches the spinner model.
228 case spinner.TickMsg:
229 //nolint:nestif // Complex UI state management requires nested conditions
230 if r.state == loadingState && r.spinner.ID() == msg.ID {
231 s, cmd := r.spinner.Update(msg)
232 r.spinner = s
233 if cmd != nil {
234 cmds = append(cmds, cmd)
235 }
236 } else {
237 for i, c := range r.panes {
238 if c.SpinnerID() == msg.ID {
239 m, cmd := c.Update(msg)
240 r.panes[i] = m.(common.TabComponent)
241 if cmd != nil {
242 cmds = append(cmds, cmd)
243 }
244 break
245 }
246 }
247 }
248 case tea.WindowSizeMsg:
249 r.SetSize(msg.Width, msg.Height)
250 cmds = append(cmds, r.updateModels(msg))
251 case EmptyRepoMsg:
252 r.ref = nil
253 r.state = readyState
254 cmds = append(cmds, r.updateModels(msg))
255 case common.ErrorMsg:
256 r.state = readyState
257 case SwitchTabMsg:
258 for i, c := range r.panes {
259 if c.TabName() == msg.TabName() {
260 cmds = append(cmds, tabs.SelectTabCmd(i))
261 break
262 }
263 }
264 }
265 active := r.panes[r.activeTab]
266 m, cmd := active.Update(msg)
267 r.panes[r.activeTab] = m.(common.TabComponent)
268 if cmd != nil {
269 cmds = append(cmds, cmd)
270 }
271
272 // Update the status bar on these events
273 // Must come after we've updated the active tab
274 switch msg.(type) {
275 case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyPressMsg,
276 tea.MouseClickMsg, tea.MouseWheelMsg, FileItemsMsg, FileContentMsg,
277 FileBlameMsg, selector.ActiveMsg, LogItemsMsg, GoBackMsg, LogDiffMsg,
278 EmptyRepoMsg, StashListMsg, StashPatchMsg:
279 r.setStatusBarInfo()
280 }
281
282 s, cmd := r.statusbar.Update(msg)
283 r.statusbar = s.(*statusbar.Model)
284 if cmd != nil {
285 cmds = append(cmds, cmd)
286 }
287
288 return r, tea.Batch(cmds...)
289}
290
291// View implements tea.Model.
292func (r *Repo) View() string {
293 wm, hm := r.getMargins()
294 hm += r.common.Styles.Tabs.GetHeight() +
295 r.common.Styles.Tabs.GetVerticalFrameSize()
296 s := r.common.Styles.Repo.Base.
297 Width(r.common.Width - wm).
298 Height(r.common.Height - hm)
299 mainStyle := r.common.Styles.Repo.Body.
300 Height(r.common.Height - hm)
301 var main string
302 var statusbar string
303 switch r.state {
304 case loadingState:
305 main = fmt.Sprintf("%s loading…", r.spinner.View())
306 case readyState:
307 main = r.panes[r.activeTab].View()
308 statusbar = r.statusbar.View()
309 }
310 main = r.common.Zone.Mark(
311 "repo-main",
312 mainStyle.Render(main),
313 )
314 view := lipgloss.JoinVertical(lipgloss.Left,
315 r.headerView(),
316 r.tabs.View(),
317 main,
318 statusbar,
319 )
320 return s.Render(view)
321}
322
323func (r *Repo) headerView() string {
324 if r.selectedRepo == nil {
325 return ""
326 }
327 truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
328 header := r.selectedRepo.ProjectName()
329 if header == "" {
330 header = r.selectedRepo.Name()
331 }
332 header = r.common.Styles.Repo.HeaderName.Render(header)
333 desc := strings.TrimSpace(r.selectedRepo.Description())
334 if desc != "" {
335 header = lipgloss.JoinVertical(lipgloss.Left,
336 header,
337 r.common.Styles.Repo.HeaderDesc.Render(desc),
338 )
339 }
340 urlStyle := r.common.Styles.URLStyle.
341 Width(r.common.Width - lipgloss.Width(header) - 1).
342 Align(lipgloss.Right)
343 var url string
344 if cfg := r.common.Config(); cfg != nil {
345 url = r.common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())
346 }
347 url = common.TruncateString(url, r.common.Width-lipgloss.Width(header)-1)
348 url = r.common.Zone.Mark(
349 fmt.Sprintf("%s-url", r.selectedRepo.Name()),
350 urlStyle.Render(url),
351 )
352
353 header = lipgloss.JoinHorizontal(lipgloss.Top, header, url)
354
355 style := r.common.Styles.Repo.Header.Width(r.common.Width)
356 return style.Render(
357 truncate.Render(header),
358 )
359}
360
361func (r *Repo) setStatusBarInfo() {
362 if r.selectedRepo == nil {
363 return
364 }
365
366 active := r.panes[r.activeTab]
367 key := r.selectedRepo.Name()
368 value := active.StatusBarValue()
369 info := active.StatusBarInfo()
370 extra := "*"
371 if r.ref != nil {
372 extra += " " + r.ref.Name().Short()
373 }
374
375 r.statusbar.SetStatus(key, value, info, extra)
376}
377
378func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {
379 cmds := make([]tea.Cmd, 0)
380 for i, b := range r.panes {
381 if b.TabName() == c.TabName() {
382 m, cmd := b.Update(msg)
383 r.panes[i] = m.(common.TabComponent)
384 if cmd != nil {
385 cmds = append(cmds, cmd)
386 }
387 break
388 }
389 }
390 return tea.Batch(cmds...)
391}
392
393func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
394 cmds := make([]tea.Cmd, 0)
395 for i, b := range r.panes {
396 m, cmd := b.Update(msg)
397 r.panes[i] = m.(common.TabComponent)
398 if cmd != nil {
399 cmds = append(cmds, cmd)
400 }
401 }
402 return tea.Batch(cmds...)
403}
404
405func copyCmd(text, msg string) tea.Cmd {
406 return func() tea.Msg {
407 return CopyMsg{
408 Text: text,
409 Message: msg,
410 }
411 }
412}
413
414func goBackCmd() tea.Msg {
415 return GoBackMsg{}
416}
417
418func switchTabCmd(m common.TabComponent) tea.Cmd {
419 return func() tea.Msg {
420 return SwitchTabMsg(m)
421 }
422}
423
424func renderLoading(c common.Common, s spinner.Model) string {
425 msg := fmt.Sprintf("%s loading…", s.View())
426 return c.Styles.SpinnerContainer.
427 Height(c.Height).
428 Render(msg)
429}