repo.go

  1package repo
  2
  3import (
  4	"fmt"
  5	"time"
  6
  7	"github.com/charmbracelet/bubbles/help"
  8	"github.com/charmbracelet/bubbles/key"
  9	"github.com/charmbracelet/bubbles/spinner"
 10	tea "github.com/charmbracelet/bubbletea"
 11	"github.com/charmbracelet/lipgloss"
 12	ggit "github.com/charmbracelet/soft-serve/git"
 13	"github.com/charmbracelet/soft-serve/ui/common"
 14	"github.com/charmbracelet/soft-serve/ui/components/footer"
 15	"github.com/charmbracelet/soft-serve/ui/components/statusbar"
 16	"github.com/charmbracelet/soft-serve/ui/components/tabs"
 17	"github.com/charmbracelet/soft-serve/ui/git"
 18)
 19
 20type state int
 21
 22const (
 23	loadingState state = iota
 24	readyState
 25)
 26
 27type tab int
 28
 29const (
 30	readmeTab tab = iota
 31	filesTab
 32	commitsTab
 33	branchesTab
 34	tagsTab
 35	lastTab
 36)
 37
 38func (t tab) String() string {
 39	return []string{
 40		"Readme",
 41		"Files",
 42		"Commits",
 43		"Branches",
 44		"Tags",
 45	}[t]
 46}
 47
 48// EmptyRepoMsg is a message to indicate that the repository is empty.
 49type EmptyRepoMsg struct{}
 50
 51// CopyURLMsg is a message to copy the URL of the current repository.
 52type CopyURLMsg struct{}
 53
 54// ResetURLMsg is a message to reset the URL string.
 55type ResetURLMsg struct{}
 56
 57// UpdateStatusBarMsg updates the status bar.
 58type UpdateStatusBarMsg struct{}
 59
 60// RepoMsg is a message that contains a git.Repository.
 61type RepoMsg *git.Repository
 62
 63// BackMsg is a message to go back to the previous view.
 64type BackMsg struct{}
 65
 66// Repo is a view for a git repository.
 67type Repo struct {
 68	common       common.Common
 69	selectedRepo *git.Repository
 70	activeTab    tab
 71	tabs         *tabs.Tabs
 72	statusbar    *statusbar.StatusBar
 73	panes        []common.Component
 74	ref          *ggit.Reference
 75	copyURL      time.Time
 76	state        state
 77	spinner      spinner.Model
 78	panesReady   [lastTab]bool
 79}
 80
 81// New returns a new Repo.
 82func New(c common.Common) *Repo {
 83	sb := statusbar.New(c)
 84	ts := make([]string, lastTab)
 85	// Tabs must match the order of tab constants above.
 86	for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
 87		ts[i] = t.String()
 88	}
 89	tb := tabs.New(c, ts)
 90	readme := NewReadme(c)
 91	log := NewLog(c)
 92	files := NewFiles(c)
 93	branches := NewRefs(c, ggit.RefsHeads)
 94	tags := NewRefs(c, ggit.RefsTags)
 95	// Make sure the order matches the order of tab constants above.
 96	panes := []common.Component{
 97		readme,
 98		files,
 99		log,
100		branches,
101		tags,
102	}
103	s := spinner.New(spinner.WithSpinner(spinner.Dot),
104		spinner.WithStyle(c.Styles.Spinner))
105	r := &Repo{
106		common:    c,
107		tabs:      tb,
108		statusbar: sb,
109		panes:     panes,
110		state:     loadingState,
111		spinner:   s,
112	}
113	return r
114}
115
116// SetSize implements common.Component.
117func (r *Repo) SetSize(width, height int) {
118	r.common.SetSize(width, height)
119	hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
120		r.common.Styles.Repo.Header.GetHeight() +
121		r.common.Styles.Repo.Header.GetVerticalFrameSize() +
122		r.common.Styles.StatusBar.GetHeight()
123	r.tabs.SetSize(width, height-hm)
124	r.statusbar.SetSize(width, height-hm)
125	for _, p := range r.panes {
126		p.SetSize(width, height-hm)
127	}
128}
129
130func (r *Repo) commonHelp() []key.Binding {
131	b := make([]key.Binding, 0)
132	back := r.common.KeyMap.Back
133	back.SetHelp("esc", "back to menu")
134	tab := r.common.KeyMap.Section
135	tab.SetHelp("tab", "switch tab")
136	b = append(b, back)
137	b = append(b, tab)
138	return b
139}
140
141// ShortHelp implements help.KeyMap.
142func (r *Repo) ShortHelp() []key.Binding {
143	b := r.commonHelp()
144	b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
145	return b
146}
147
148// FullHelp implements help.KeyMap.
149func (r *Repo) FullHelp() [][]key.Binding {
150	b := make([][]key.Binding, 0)
151	b = append(b, r.commonHelp())
152	b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
153	return b
154}
155
156// Init implements tea.View.
157func (r *Repo) Init() tea.Cmd {
158	return tea.Batch(
159		r.tabs.Init(),
160		r.statusbar.Init(),
161	)
162}
163
164// Update implements tea.Model.
165func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
166	cmds := make([]tea.Cmd, 0)
167	switch msg := msg.(type) {
168	case RepoMsg:
169		// Set the state to loading when we get a new repository.
170		r.state = loadingState
171		r.panesReady = [lastTab]bool{}
172		r.activeTab = 0
173		r.selectedRepo = msg
174		cmds = append(cmds,
175			r.tabs.Init(),
176			// This will set the selected repo in each pane's model.
177			r.updateModels(msg),
178		)
179	case RefMsg:
180		r.ref = msg
181		for _, p := range r.panes {
182			// Init will initiate each pane's model with its contents.
183			cmds = append(cmds, p.Init())
184		}
185		cmds = append(cmds,
186			r.updateStatusBarCmd,
187			r.updateModels(msg),
188		)
189	case tabs.SelectTabMsg:
190		r.activeTab = tab(msg)
191		t, cmd := r.tabs.Update(msg)
192		r.tabs = t.(*tabs.Tabs)
193		if cmd != nil {
194			cmds = append(cmds, cmd)
195		}
196	case tabs.ActiveTabMsg:
197		r.activeTab = tab(msg)
198		if r.selectedRepo != nil {
199			cmds = append(cmds,
200				r.updateStatusBarCmd,
201			)
202		}
203	case tea.KeyMsg, tea.MouseMsg:
204		t, cmd := r.tabs.Update(msg)
205		r.tabs = t.(*tabs.Tabs)
206		if cmd != nil {
207			cmds = append(cmds, cmd)
208		}
209		if r.selectedRepo != nil {
210			cmds = append(cmds, r.updateStatusBarCmd)
211			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Info.Name())
212			if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
213				cmds = append(cmds, r.copyURLCmd())
214			}
215		}
216		switch msg := msg.(type) {
217		case tea.MouseMsg:
218			switch msg.Type {
219			case tea.MouseLeft:
220				switch {
221				case r.common.Zone.Get("repo-help").InBounds(msg):
222					cmds = append(cmds, footer.ToggleFooterCmd)
223				}
224			case tea.MouseRight:
225				switch {
226				case r.common.Zone.Get("repo-main").InBounds(msg):
227					cmds = append(cmds, backCmd)
228				}
229			}
230		}
231	case CopyURLMsg:
232		if cfg := r.common.Config(); cfg != nil {
233			host := cfg.Host
234			port := cfg.SSH.Port
235			r.common.Copy.Copy(
236				git.RepoURL(host, port, r.selectedRepo.Info.Name()),
237			)
238		}
239	case ResetURLMsg:
240		r.copyURL = time.Time{}
241	case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
242		cmds = append(cmds, r.updateRepo(msg))
243	// We have two spinners, one is used to when loading the repository and the
244	// other is used when loading the log.
245	// Check if the spinner ID matches the spinner model.
246	case spinner.TickMsg:
247		switch msg.ID {
248		case r.spinner.ID():
249			if r.state == loadingState {
250				s, cmd := r.spinner.Update(msg)
251				r.spinner = s
252				if cmd != nil {
253					cmds = append(cmds, cmd)
254				}
255			}
256		default:
257			cmds = append(cmds, r.updateRepo(msg))
258		}
259	case UpdateStatusBarMsg:
260		cmds = append(cmds, r.updateStatusBarCmd)
261	case tea.WindowSizeMsg:
262		cmds = append(cmds, r.updateModels(msg))
263	case EmptyRepoMsg:
264		r.ref = nil
265		r.state = readyState
266		cmds = append(cmds,
267			r.updateModels(msg),
268			r.updateStatusBarCmd,
269		)
270	case common.ErrorMsg:
271		r.state = readyState
272	}
273	s, cmd := r.statusbar.Update(msg)
274	r.statusbar = s.(*statusbar.StatusBar)
275	if cmd != nil {
276		cmds = append(cmds, cmd)
277	}
278	m, cmd := r.panes[r.activeTab].Update(msg)
279	r.panes[r.activeTab] = m.(common.Component)
280	if cmd != nil {
281		cmds = append(cmds, cmd)
282	}
283	return r, tea.Batch(cmds...)
284}
285
286// View implements tea.Model.
287func (r *Repo) View() string {
288	s := r.common.Styles.Repo.Base.Copy().
289		Width(r.common.Width).
290		Height(r.common.Height)
291	repoBodyStyle := r.common.Styles.Repo.Body.Copy()
292	hm := repoBodyStyle.GetVerticalFrameSize() +
293		r.common.Styles.Repo.Header.GetHeight() +
294		r.common.Styles.Repo.Header.GetVerticalFrameSize() +
295		r.common.Styles.StatusBar.GetHeight() +
296		r.common.Styles.Tabs.GetHeight() +
297		r.common.Styles.Tabs.GetVerticalFrameSize()
298	mainStyle := repoBodyStyle.
299		Height(r.common.Height - hm)
300	var main string
301	var statusbar string
302	switch r.state {
303	case loadingState:
304		main = fmt.Sprintf("%s loading…", r.spinner.View())
305	case readyState:
306		main = r.panes[r.activeTab].View()
307		statusbar = r.statusbar.View()
308	}
309	main = r.common.Zone.Mark(
310		"repo-main",
311		mainStyle.Render(main),
312	)
313	view := lipgloss.JoinVertical(lipgloss.Top,
314		r.headerView(),
315		r.tabs.View(),
316		main,
317		statusbar,
318	)
319	return s.Render(view)
320}
321
322func (r *Repo) headerView() string {
323	if r.selectedRepo == nil {
324		return ""
325	}
326	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
327	name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Info.Name())
328	desc := r.selectedRepo.Info.Description()
329	if desc == "" {
330		desc = name
331		name = ""
332	} else {
333		desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
334	}
335	urlStyle := r.common.Styles.URLStyle.Copy().
336		Width(r.common.Width - lipgloss.Width(desc) - 1).
337		Align(lipgloss.Right)
338	var url string
339	if cfg := r.common.Config(); cfg != nil {
340		url = git.RepoURL(cfg.Host, cfg.SSH.Port, r.selectedRepo.Info.Name())
341	}
342	if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
343		url = "copied!"
344	}
345	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
346	url = r.common.Zone.Mark(
347		fmt.Sprintf("%s-url", r.selectedRepo.Info.Name()),
348		urlStyle.Render(url),
349	)
350	style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
351	return style.Render(
352		lipgloss.JoinVertical(lipgloss.Top,
353			truncate.Render(name),
354			truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
355				desc,
356				url,
357			)),
358		),
359	)
360}
361
362func (r *Repo) updateStatusBarCmd() tea.Msg {
363	if r.selectedRepo == nil {
364		return nil
365	}
366	value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
367	info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
368	branch := "*"
369	if r.ref != nil {
370		branch += " " + r.ref.Name().Short()
371	}
372	return statusbar.StatusBarMsg{
373		Key:    r.selectedRepo.Info.Name(),
374		Value:  value,
375		Info:   info,
376		Branch: branch,
377	}
378}
379
380func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
381	cmds := make([]tea.Cmd, 0)
382	for i, b := range r.panes {
383		m, cmd := b.Update(msg)
384		r.panes[i] = m.(common.Component)
385		if cmd != nil {
386			cmds = append(cmds, cmd)
387		}
388	}
389	return tea.Batch(cmds...)
390}
391
392func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
393	cmds := make([]tea.Cmd, 0)
394	switch msg := msg.(type) {
395	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
396		switch msg.(type) {
397		case LogItemsMsg:
398			r.panesReady[commitsTab] = true
399		}
400		l, cmd := r.panes[commitsTab].Update(msg)
401		r.panes[commitsTab] = l.(*Log)
402		if cmd != nil {
403			cmds = append(cmds, cmd)
404		}
405	case FileItemsMsg:
406		r.panesReady[filesTab] = true
407		f, cmd := r.panes[filesTab].Update(msg)
408		r.panes[filesTab] = f.(*Files)
409		if cmd != nil {
410			cmds = append(cmds, cmd)
411		}
412	case RefItemsMsg:
413		switch msg.prefix {
414		case ggit.RefsHeads:
415			r.panesReady[branchesTab] = true
416			b, cmd := r.panes[branchesTab].Update(msg)
417			r.panes[branchesTab] = b.(*Refs)
418			if cmd != nil {
419				cmds = append(cmds, cmd)
420			}
421		case ggit.RefsTags:
422			r.panesReady[tagsTab] = true
423			t, cmd := r.panes[tagsTab].Update(msg)
424			r.panes[tagsTab] = t.(*Refs)
425			if cmd != nil {
426				cmds = append(cmds, cmd)
427			}
428		}
429	case ReadmeMsg:
430		r.panesReady[readmeTab] = true
431	}
432	if r.isReady() {
433		r.state = readyState
434	}
435	return tea.Batch(cmds...)
436}
437
438func (r *Repo) isReady() bool {
439	ready := true
440	// We purposely ignore the log pane here because it has its own spinner.
441	for _, b := range []bool{
442		r.panesReady[filesTab], r.panesReady[branchesTab],
443		r.panesReady[tagsTab], r.panesReady[readmeTab],
444	} {
445		if !b {
446			ready = false
447			break
448		}
449	}
450	return ready
451}
452
453func (r *Repo) copyURLCmd() tea.Cmd {
454	r.copyURL = time.Now()
455	return tea.Batch(
456		func() tea.Msg {
457			return CopyURLMsg{}
458		},
459		tea.Tick(time.Second, func(time.Time) tea.Msg {
460			return ResetURLMsg{}
461		}),
462	)
463}
464
465func updateStatusBarCmd() tea.Msg {
466	return UpdateStatusBarMsg{}
467}
468
469func backCmd() tea.Msg {
470	return BackMsg{}
471}