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