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