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			r.common.Copy.Copy(
239				common.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name()),
240			)
241		}
242	case ResetURLMsg:
243		r.copyURL = time.Time{}
244	case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
245		cmds = append(cmds, r.updateRepo(msg))
246	// We have two spinners, one is used to when loading the repository and the
247	// other is used when loading the log.
248	// Check if the spinner ID matches the spinner model.
249	case spinner.TickMsg:
250		switch msg.ID {
251		case r.spinner.ID():
252			if r.state == loadingState {
253				s, cmd := r.spinner.Update(msg)
254				r.spinner = s
255				if cmd != nil {
256					cmds = append(cmds, cmd)
257				}
258			}
259		default:
260			cmds = append(cmds, r.updateRepo(msg))
261		}
262	case UpdateStatusBarMsg:
263		cmds = append(cmds, r.updateStatusBarCmd)
264	case tea.WindowSizeMsg:
265		cmds = append(cmds, r.updateModels(msg))
266	case EmptyRepoMsg:
267		r.ref = nil
268		r.state = readyState
269		cmds = append(cmds,
270			r.updateModels(msg),
271			r.updateStatusBarCmd,
272		)
273	case common.ErrorMsg:
274		r.state = readyState
275	}
276	s, cmd := r.statusbar.Update(msg)
277	r.statusbar = s.(*statusbar.StatusBar)
278	if cmd != nil {
279		cmds = append(cmds, cmd)
280	}
281	m, cmd := r.panes[r.activeTab].Update(msg)
282	r.panes[r.activeTab] = m.(common.Component)
283	if cmd != nil {
284		cmds = append(cmds, cmd)
285	}
286	return r, tea.Batch(cmds...)
287}
288
289// View implements tea.Model.
290func (r *Repo) View() string {
291	s := r.common.Styles.Repo.Base.Copy().
292		Width(r.common.Width).
293		Height(r.common.Height)
294	repoBodyStyle := r.common.Styles.Repo.Body.Copy()
295	hm := repoBodyStyle.GetVerticalFrameSize() +
296		r.common.Styles.Repo.Header.GetHeight() +
297		r.common.Styles.Repo.Header.GetVerticalFrameSize() +
298		r.common.Styles.StatusBar.GetHeight() +
299		r.common.Styles.Tabs.GetHeight() +
300		r.common.Styles.Tabs.GetVerticalFrameSize()
301	mainStyle := repoBodyStyle.
302		Height(r.common.Height - hm)
303	var main string
304	var statusbar string
305	switch r.state {
306	case loadingState:
307		main = fmt.Sprintf("%s loading…", r.spinner.View())
308	case readyState:
309		main = r.panes[r.activeTab].View()
310		statusbar = r.statusbar.View()
311	}
312	main = r.common.Zone.Mark(
313		"repo-main",
314		mainStyle.Render(main),
315	)
316	view := lipgloss.JoinVertical(lipgloss.Top,
317		r.headerView(),
318		r.tabs.View(),
319		main,
320		statusbar,
321	)
322	return s.Render(view)
323}
324
325func (r *Repo) headerView() string {
326	if r.selectedRepo == nil {
327		return ""
328	}
329	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
330	name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Name())
331	desc := r.selectedRepo.Description()
332	if desc == "" {
333		desc = name
334		name = ""
335	} else {
336		desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
337	}
338	urlStyle := r.common.Styles.URLStyle.Copy().
339		Width(r.common.Width - lipgloss.Width(desc) - 1).
340		Align(lipgloss.Right)
341	var url string
342	if cfg := r.common.Config(); cfg != nil {
343		url = common.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name())
344	}
345	if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
346		url = "copied!"
347	}
348	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
349	url = r.common.Zone.Mark(
350		fmt.Sprintf("%s-url", r.selectedRepo.Name()),
351		urlStyle.Render(url),
352	)
353	style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
354	return style.Render(
355		lipgloss.JoinVertical(lipgloss.Top,
356			truncate.Render(name),
357			truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
358				desc,
359				url,
360			)),
361		),
362	)
363}
364
365func (r *Repo) updateStatusBarCmd() tea.Msg {
366	if r.selectedRepo == nil {
367		return nil
368	}
369	value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
370	info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
371	branch := "*"
372	if r.ref != nil {
373		branch += " " + r.ref.Name().Short()
374	}
375	return statusbar.StatusBarMsg{
376		Key:    r.selectedRepo.Name(),
377		Value:  value,
378		Info:   info,
379		Branch: branch,
380	}
381}
382
383func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
384	cmds := make([]tea.Cmd, 0)
385	for i, b := range r.panes {
386		m, cmd := b.Update(msg)
387		r.panes[i] = m.(common.Component)
388		if cmd != nil {
389			cmds = append(cmds, cmd)
390		}
391	}
392	return tea.Batch(cmds...)
393}
394
395func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
396	cmds := make([]tea.Cmd, 0)
397	switch msg := msg.(type) {
398	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
399		switch msg.(type) {
400		case LogItemsMsg:
401			r.panesReady[commitsTab] = true
402		}
403		l, cmd := r.panes[commitsTab].Update(msg)
404		r.panes[commitsTab] = l.(*Log)
405		if cmd != nil {
406			cmds = append(cmds, cmd)
407		}
408	case FileItemsMsg:
409		r.panesReady[filesTab] = true
410		f, cmd := r.panes[filesTab].Update(msg)
411		r.panes[filesTab] = f.(*Files)
412		if cmd != nil {
413			cmds = append(cmds, cmd)
414		}
415	case RefItemsMsg:
416		switch msg.prefix {
417		case git.RefsHeads:
418			r.panesReady[branchesTab] = true
419			b, cmd := r.panes[branchesTab].Update(msg)
420			r.panes[branchesTab] = b.(*Refs)
421			if cmd != nil {
422				cmds = append(cmds, cmd)
423			}
424		case git.RefsTags:
425			r.panesReady[tagsTab] = true
426			t, cmd := r.panes[tagsTab].Update(msg)
427			r.panes[tagsTab] = t.(*Refs)
428			if cmd != nil {
429				cmds = append(cmds, cmd)
430			}
431		}
432	case ReadmeMsg:
433		r.panesReady[readmeTab] = true
434	}
435	if r.isReady() {
436		r.state = readyState
437	}
438	return tea.Batch(cmds...)
439}
440
441func (r *Repo) isReady() bool {
442	ready := true
443	// We purposely ignore the log pane here because it has its own spinner.
444	for _, b := range []bool{
445		r.panesReady[filesTab], r.panesReady[branchesTab],
446		r.panesReady[tagsTab], r.panesReady[readmeTab],
447	} {
448		if !b {
449			ready = false
450			break
451		}
452	}
453	return ready
454}
455
456func (r *Repo) copyURLCmd() tea.Cmd {
457	r.copyURL = time.Now()
458	return tea.Batch(
459		func() tea.Msg {
460			return CopyURLMsg{}
461		},
462		tea.Tick(time.Second, func(time.Time) tea.Msg {
463			return ResetURLMsg{}
464		}),
465	)
466}
467
468func updateStatusBarCmd() tea.Msg {
469	return UpdateStatusBarMsg{}
470}
471
472func backCmd() tea.Msg {
473	return BackMsg{}
474}