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