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