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