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.selectedRepo.ProjectName()
331	if name == "" {
332		name = r.selectedRepo.Name()
333	}
334	name = r.common.Styles.Repo.HeaderName.Render(name)
335	desc := r.selectedRepo.Description()
336	if desc == "" {
337		desc = name
338		name = ""
339	} else {
340		desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
341	}
342	urlStyle := r.common.Styles.URLStyle.Copy().
343		Width(r.common.Width - lipgloss.Width(desc) - 1).
344		Align(lipgloss.Right)
345	var url string
346	if cfg := r.common.Config(); cfg != nil {
347		url = common.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name())
348	}
349	if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
350		url = "copied!"
351	}
352	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
353	url = r.common.Zone.Mark(
354		fmt.Sprintf("%s-url", r.selectedRepo.Name()),
355		urlStyle.Render(url),
356	)
357	style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
358	return style.Render(
359		lipgloss.JoinVertical(lipgloss.Top,
360			truncate.Render(name),
361			truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
362				desc,
363				url,
364			)),
365		),
366	)
367}
368
369func (r *Repo) updateStatusBarCmd() tea.Msg {
370	if r.selectedRepo == nil {
371		return nil
372	}
373	value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
374	info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
375	branch := "*"
376	if r.ref != nil {
377		branch += " " + r.ref.Name().Short()
378	}
379	return statusbar.StatusBarMsg{
380		Key:    r.selectedRepo.Name(),
381		Value:  value,
382		Info:   info,
383		Branch: branch,
384	}
385}
386
387func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
388	cmds := make([]tea.Cmd, 0)
389	for i, b := range r.panes {
390		m, cmd := b.Update(msg)
391		r.panes[i] = m.(common.Component)
392		if cmd != nil {
393			cmds = append(cmds, cmd)
394		}
395	}
396	return tea.Batch(cmds...)
397}
398
399func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
400	cmds := make([]tea.Cmd, 0)
401	switch msg := msg.(type) {
402	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
403		switch msg.(type) {
404		case LogItemsMsg:
405			r.panesReady[commitsTab] = true
406		}
407		l, cmd := r.panes[commitsTab].Update(msg)
408		r.panes[commitsTab] = l.(*Log)
409		if cmd != nil {
410			cmds = append(cmds, cmd)
411		}
412	case FileItemsMsg:
413		r.panesReady[filesTab] = true
414		f, cmd := r.panes[filesTab].Update(msg)
415		r.panes[filesTab] = f.(*Files)
416		if cmd != nil {
417			cmds = append(cmds, cmd)
418		}
419	case RefItemsMsg:
420		switch msg.prefix {
421		case git.RefsHeads:
422			r.panesReady[branchesTab] = true
423			b, cmd := r.panes[branchesTab].Update(msg)
424			r.panes[branchesTab] = b.(*Refs)
425			if cmd != nil {
426				cmds = append(cmds, cmd)
427			}
428		case git.RefsTags:
429			r.panesReady[tagsTab] = true
430			t, cmd := r.panes[tagsTab].Update(msg)
431			r.panes[tagsTab] = t.(*Refs)
432			if cmd != nil {
433				cmds = append(cmds, cmd)
434			}
435		}
436	case ReadmeMsg:
437		r.panesReady[readmeTab] = true
438	}
439	if r.isReady() {
440		r.state = readyState
441	}
442	return tea.Batch(cmds...)
443}
444
445func (r *Repo) isReady() bool {
446	ready := true
447	// We purposely ignore the log pane here because it has its own spinner.
448	for _, b := range []bool{
449		r.panesReady[filesTab], r.panesReady[branchesTab],
450		r.panesReady[tagsTab], r.panesReady[readmeTab],
451	} {
452		if !b {
453			ready = false
454			break
455		}
456	}
457	return ready
458}
459
460func (r *Repo) copyURLCmd() tea.Cmd {
461	r.copyURL = time.Now()
462	return tea.Batch(
463		func() tea.Msg {
464			return CopyURLMsg{}
465		},
466		tea.Tick(time.Second, func(time.Time) tea.Msg {
467			return ResetURLMsg{}
468		}),
469	)
470}
471
472func updateStatusBarCmd() tea.Msg {
473	return UpdateStatusBarMsg{}
474}
475
476func backCmd() tea.Msg {
477	return BackMsg{}
478}