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	"github.com/charmbracelet/log"
 13	"github.com/charmbracelet/soft-serve/git"
 14	"github.com/charmbracelet/soft-serve/server/backend"
 15	"github.com/charmbracelet/soft-serve/ui/common"
 16	"github.com/charmbracelet/soft-serve/ui/components/footer"
 17	"github.com/charmbracelet/soft-serve/ui/components/statusbar"
 18	"github.com/charmbracelet/soft-serve/ui/components/tabs"
 19)
 20
 21var (
 22	logger = log.WithPrefix("ui.repo")
 23)
 24
 25type state int
 26
 27const (
 28	loadingState state = iota
 29	readyState
 30)
 31
 32type tab int
 33
 34const (
 35	readmeTab tab = iota
 36	filesTab
 37	commitsTab
 38	branchesTab
 39	tagsTab
 40	lastTab
 41)
 42
 43func (t tab) String() string {
 44	return []string{
 45		"Readme",
 46		"Files",
 47		"Commits",
 48		"Branches",
 49		"Tags",
 50	}[t]
 51}
 52
 53// EmptyRepoMsg is a message to indicate that the repository is empty.
 54type EmptyRepoMsg struct{}
 55
 56// CopyURLMsg is a message to copy the URL of the current repository.
 57type CopyURLMsg struct{}
 58
 59// ResetURLMsg is a message to reset the URL string.
 60type ResetURLMsg struct{}
 61
 62// UpdateStatusBarMsg updates the status bar.
 63type UpdateStatusBarMsg struct{}
 64
 65// RepoMsg is a message that contains a git.Repository.
 66type RepoMsg backend.Repository
 67
 68// BackMsg is a message to go back to the previous view.
 69type BackMsg struct{}
 70
 71// Repo is a view for a git repository.
 72type Repo struct {
 73	common       common.Common
 74	selectedRepo backend.Repository
 75	activeTab    tab
 76	tabs         *tabs.Tabs
 77	statusbar    *statusbar.StatusBar
 78	panes        []common.Component
 79	ref          *git.Reference
 80	copyURL      time.Time
 81	state        state
 82	spinner      spinner.Model
 83	panesReady   [lastTab]bool
 84}
 85
 86// New returns a new Repo.
 87func New(c common.Common) *Repo {
 88	sb := statusbar.New(c)
 89	ts := make([]string, lastTab)
 90	// Tabs must match the order of tab constants above.
 91	for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
 92		ts[i] = t.String()
 93	}
 94	tb := tabs.New(c, ts)
 95	readme := NewReadme(c)
 96	log := NewLog(c)
 97	files := NewFiles(c)
 98	branches := NewRefs(c, git.RefsHeads)
 99	tags := NewRefs(c, git.RefsTags)
100	// Make sure the order matches the order of tab constants above.
101	panes := []common.Component{
102		readme,
103		files,
104		log,
105		branches,
106		tags,
107	}
108	s := spinner.New(spinner.WithSpinner(spinner.Dot),
109		spinner.WithStyle(c.Styles.Spinner))
110	r := &Repo{
111		common:    c,
112		tabs:      tb,
113		statusbar: sb,
114		panes:     panes,
115		state:     loadingState,
116		spinner:   s,
117	}
118	return r
119}
120
121// SetSize implements common.Component.
122func (r *Repo) SetSize(width, height int) {
123	r.common.SetSize(width, height)
124	hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
125		r.common.Styles.Repo.Header.GetHeight() +
126		r.common.Styles.Repo.Header.GetVerticalFrameSize() +
127		r.common.Styles.StatusBar.GetHeight()
128	r.tabs.SetSize(width, height-hm)
129	r.statusbar.SetSize(width, height-hm)
130	for _, p := range r.panes {
131		p.SetSize(width, height-hm)
132	}
133}
134
135func (r *Repo) commonHelp() []key.Binding {
136	b := make([]key.Binding, 0)
137	back := r.common.KeyMap.Back
138	back.SetHelp("esc", "back to menu")
139	tab := r.common.KeyMap.Section
140	tab.SetHelp("tab", "switch tab")
141	b = append(b, back)
142	b = append(b, tab)
143	return b
144}
145
146// ShortHelp implements help.KeyMap.
147func (r *Repo) ShortHelp() []key.Binding {
148	b := r.commonHelp()
149	b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
150	return b
151}
152
153// FullHelp implements help.KeyMap.
154func (r *Repo) FullHelp() [][]key.Binding {
155	b := make([][]key.Binding, 0)
156	b = append(b, r.commonHelp())
157	b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
158	return b
159}
160
161// Init implements tea.View.
162func (r *Repo) Init() tea.Cmd {
163	return tea.Batch(
164		r.tabs.Init(),
165		r.statusbar.Init(),
166	)
167}
168
169// Update implements tea.Model.
170func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171	cmds := make([]tea.Cmd, 0)
172	switch msg := msg.(type) {
173	case RepoMsg:
174		// Set the state to loading when we get a new repository.
175		r.state = loadingState
176		r.panesReady = [lastTab]bool{}
177		r.activeTab = 0
178		r.selectedRepo = msg
179		cmds = append(cmds,
180			r.tabs.Init(),
181			// This will set the selected repo in each pane's model.
182			r.updateModels(msg),
183		)
184	case RefMsg:
185		r.ref = msg
186		for _, p := range r.panes {
187			// Init will initiate each pane's model with its contents.
188			cmds = append(cmds, p.Init())
189		}
190		cmds = append(cmds,
191			r.updateStatusBarCmd,
192			r.updateModels(msg),
193		)
194	case tabs.SelectTabMsg:
195		r.activeTab = tab(msg)
196		t, cmd := r.tabs.Update(msg)
197		r.tabs = t.(*tabs.Tabs)
198		if cmd != nil {
199			cmds = append(cmds, cmd)
200		}
201	case tabs.ActiveTabMsg:
202		r.activeTab = tab(msg)
203		if r.selectedRepo != nil {
204			cmds = append(cmds,
205				r.updateStatusBarCmd,
206			)
207		}
208	case tea.KeyMsg, tea.MouseMsg:
209		t, cmd := r.tabs.Update(msg)
210		r.tabs = t.(*tabs.Tabs)
211		if cmd != nil {
212			cmds = append(cmds, cmd)
213		}
214		if r.selectedRepo != nil {
215			cmds = append(cmds, r.updateStatusBarCmd)
216			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
217			if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
218				cmds = append(cmds, r.copyURLCmd())
219			}
220		}
221		switch msg := msg.(type) {
222		case tea.MouseMsg:
223			switch msg.Type {
224			case tea.MouseLeft:
225				switch {
226				case r.common.Zone.Get("repo-help").InBounds(msg):
227					cmds = append(cmds, footer.ToggleFooterCmd)
228				}
229			case tea.MouseRight:
230				switch {
231				case r.common.Zone.Get("repo-main").InBounds(msg):
232					cmds = append(cmds, backCmd)
233				}
234			}
235		}
236	case CopyURLMsg:
237		if cfg := r.common.Config(); cfg != nil {
238			host := cfg.Backend.ServerHost()
239			port := cfg.Backend.ServerPort()
240			r.common.Copy.Copy(
241				common.RepoURL(host, port, r.selectedRepo.Name()),
242			)
243		}
244	case ResetURLMsg:
245		r.copyURL = time.Time{}
246	case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
247		cmds = append(cmds, r.updateRepo(msg))
248	// We have two spinners, one is used to when loading the repository and the
249	// other is used when loading the log.
250	// Check if the spinner ID matches the spinner model.
251	case spinner.TickMsg:
252		switch msg.ID {
253		case r.spinner.ID():
254			if r.state == loadingState {
255				s, cmd := r.spinner.Update(msg)
256				r.spinner = s
257				if cmd != nil {
258					cmds = append(cmds, cmd)
259				}
260			}
261		default:
262			cmds = append(cmds, r.updateRepo(msg))
263		}
264	case UpdateStatusBarMsg:
265		cmds = append(cmds, r.updateStatusBarCmd)
266	case tea.WindowSizeMsg:
267		cmds = append(cmds, r.updateModels(msg))
268	case EmptyRepoMsg:
269		r.ref = nil
270		r.state = readyState
271		cmds = append(cmds,
272			r.updateModels(msg),
273			r.updateStatusBarCmd,
274		)
275	case common.ErrorMsg:
276		r.state = readyState
277	}
278	s, cmd := r.statusbar.Update(msg)
279	r.statusbar = s.(*statusbar.StatusBar)
280	if cmd != nil {
281		cmds = append(cmds, cmd)
282	}
283	m, cmd := r.panes[r.activeTab].Update(msg)
284	r.panes[r.activeTab] = m.(common.Component)
285	if cmd != nil {
286		cmds = append(cmds, cmd)
287	}
288	return r, tea.Batch(cmds...)
289}
290
291// View implements tea.Model.
292func (r *Repo) View() string {
293	s := r.common.Styles.Repo.Base.Copy().
294		Width(r.common.Width).
295		Height(r.common.Height)
296	repoBodyStyle := r.common.Styles.Repo.Body.Copy()
297	hm := repoBodyStyle.GetVerticalFrameSize() +
298		r.common.Styles.Repo.Header.GetHeight() +
299		r.common.Styles.Repo.Header.GetVerticalFrameSize() +
300		r.common.Styles.StatusBar.GetHeight() +
301		r.common.Styles.Tabs.GetHeight() +
302		r.common.Styles.Tabs.GetVerticalFrameSize()
303	mainStyle := repoBodyStyle.
304		Height(r.common.Height - hm)
305	var main string
306	var statusbar string
307	switch r.state {
308	case loadingState:
309		main = fmt.Sprintf("%s loading…", r.spinner.View())
310	case readyState:
311		main = r.panes[r.activeTab].View()
312		statusbar = r.statusbar.View()
313	}
314	main = r.common.Zone.Mark(
315		"repo-main",
316		mainStyle.Render(main),
317	)
318	view := lipgloss.JoinVertical(lipgloss.Top,
319		r.headerView(),
320		r.tabs.View(),
321		main,
322		statusbar,
323	)
324	return s.Render(view)
325}
326
327func (r *Repo) headerView() string {
328	if r.selectedRepo == nil {
329		return ""
330	}
331	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
332	name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Name())
333	desc := r.selectedRepo.Description()
334	if desc == "" {
335		desc = name
336		name = ""
337	} else {
338		desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
339	}
340	urlStyle := r.common.Styles.URLStyle.Copy().
341		Width(r.common.Width - lipgloss.Width(desc) - 1).
342		Align(lipgloss.Right)
343	var url string
344	if cfg := r.common.Config(); cfg != nil {
345		url = common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), r.selectedRepo.Name())
346	}
347	if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
348		url = "copied!"
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		Branch: 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 (r *Repo) copyURLCmd() tea.Cmd {
459	r.copyURL = time.Now()
460	return tea.Batch(
461		func() tea.Msg {
462			return CopyURLMsg{}
463		},
464		tea.Tick(time.Second, func(time.Time) tea.Msg {
465			return ResetURLMsg{}
466		}),
467	)
468}
469
470func updateStatusBarCmd() tea.Msg {
471	return UpdateStatusBarMsg{}
472}
473
474func backCmd() tea.Msg {
475	return BackMsg{}
476}