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