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