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