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