repo.go

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