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