repo.go

  1package repo
  2
  3import (
  4	"fmt"
  5	"strings"
  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/git"
 13	"github.com/charmbracelet/soft-serve/pkg/proto"
 14	"github.com/charmbracelet/soft-serve/pkg/ui/common"
 15	"github.com/charmbracelet/soft-serve/pkg/ui/components/footer"
 16	"github.com/charmbracelet/soft-serve/pkg/ui/components/selector"
 17	"github.com/charmbracelet/soft-serve/pkg/ui/components/statusbar"
 18	"github.com/charmbracelet/soft-serve/pkg/ui/components/tabs"
 19)
 20
 21type state int
 22
 23const (
 24	loadingState state = iota
 25	readyState
 26)
 27
 28// EmptyRepoMsg is a message to indicate that the repository is empty.
 29type EmptyRepoMsg struct{}
 30
 31// CopyURLMsg is a message to copy the URL of the current repository.
 32type CopyURLMsg struct{}
 33
 34// RepoMsg is a message that contains a git.Repository.
 35type RepoMsg proto.Repository // nolint:revive
 36
 37// GoBackMsg is a message to go back to the previous view.
 38type GoBackMsg struct{}
 39
 40// CopyMsg is a message to indicate copied text.
 41type CopyMsg struct {
 42	Text    string
 43	Message string
 44}
 45
 46// SwitchTabMsg is a message to switch tabs.
 47type SwitchTabMsg common.TabComponent
 48
 49// Repo is a view for a git repository.
 50type Repo struct {
 51	common       common.Common
 52	selectedRepo proto.Repository
 53	activeTab    int
 54	tabs         *tabs.Tabs
 55	statusbar    *statusbar.Model
 56	panes        []common.TabComponent
 57	ref          *git.Reference
 58	state        state
 59	spinner      spinner.Model
 60	panesReady   []bool
 61}
 62
 63// New returns a new Repo.
 64func New(c common.Common, comps ...common.TabComponent) *Repo {
 65	sb := statusbar.New(c)
 66	ts := make([]string, 0)
 67	for _, c := range comps {
 68		ts = append(ts, c.TabName())
 69	}
 70	c.Logger = c.Logger.WithPrefix("ui.repo")
 71	tb := tabs.New(c, ts)
 72	// Make sure the order matches the order of tab constants above.
 73	s := spinner.New(spinner.WithSpinner(spinner.Dot),
 74		spinner.WithStyle(c.Styles.Spinner))
 75	r := &Repo{
 76		common:     c,
 77		tabs:       tb,
 78		statusbar:  sb,
 79		panes:      comps,
 80		state:      loadingState,
 81		spinner:    s,
 82		panesReady: make([]bool, len(comps)),
 83	}
 84	return r
 85}
 86
 87func (r *Repo) getMargins() (int, int) {
 88	hh := lipgloss.Height(r.headerView())
 89	hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
 90		hh +
 91		r.common.Styles.Repo.Header.GetVerticalFrameSize() +
 92		r.common.Styles.StatusBar.GetHeight()
 93	return 0, hm
 94}
 95
 96// SetSize implements common.Component.
 97func (r *Repo) SetSize(width, height int) {
 98	r.common.SetSize(width, height)
 99	_, hm := r.getMargins()
100	r.tabs.SetSize(width, height-hm)
101	r.statusbar.SetSize(width, height-hm)
102	for _, p := range r.panes {
103		p.SetSize(width, height-hm)
104	}
105}
106
107func (r *Repo) commonHelp() []key.Binding {
108	b := make([]key.Binding, 0)
109	back := r.common.KeyMap.Back
110	back.SetHelp("esc", "back to menu")
111	tab := r.common.KeyMap.Section
112	tab.SetHelp("tab", "switch tab")
113	b = append(b, back)
114	b = append(b, tab)
115	return b
116}
117
118// ShortHelp implements help.KeyMap.
119func (r *Repo) ShortHelp() []key.Binding {
120	b := r.commonHelp()
121	b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
122	return b
123}
124
125// FullHelp implements help.KeyMap.
126func (r *Repo) FullHelp() [][]key.Binding {
127	b := make([][]key.Binding, 0)
128	b = append(b, r.commonHelp())
129	b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
130	return b
131}
132
133// Init implements tea.View.
134func (r *Repo) Init() tea.Cmd {
135	r.state = loadingState
136	r.activeTab = 0
137	return tea.Batch(
138		r.tabs.Init(),
139		r.statusbar.Init(),
140		r.spinner.Tick,
141	)
142}
143
144// Update implements tea.Model.
145func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146	cmds := make([]tea.Cmd, 0)
147	switch msg := msg.(type) {
148	case RepoMsg:
149		// Set the state to loading when we get a new repository.
150		r.selectedRepo = msg
151		cmds = append(cmds,
152			r.Init(),
153			// This will set the selected repo in each pane's model.
154			r.updateModels(msg),
155		)
156	case RefMsg:
157		r.ref = msg
158		cmds = append(cmds, r.updateModels(msg))
159		r.state = readyState
160	case tabs.SelectTabMsg:
161		r.activeTab = int(msg)
162		t, cmd := r.tabs.Update(msg)
163		r.tabs = t.(*tabs.Tabs)
164		if cmd != nil {
165			cmds = append(cmds, cmd)
166		}
167	case tabs.ActiveTabMsg:
168		r.activeTab = int(msg)
169	case tea.KeyMsg, tea.MouseMsg:
170		t, cmd := r.tabs.Update(msg)
171		r.tabs = t.(*tabs.Tabs)
172		if cmd != nil {
173			cmds = append(cmds, cmd)
174		}
175		if r.selectedRepo != nil {
176			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
177			cmd := r.common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())
178			if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
179				cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard"))
180			}
181		}
182		switch msg := msg.(type) {
183		case tea.MouseMsg:
184			switch msg.Type {
185			case tea.MouseLeft:
186				switch {
187				case r.common.Zone.Get("repo-help").InBounds(msg):
188					cmds = append(cmds, footer.ToggleFooterCmd)
189				}
190			case tea.MouseRight:
191				switch {
192				case r.common.Zone.Get("repo-main").InBounds(msg):
193					cmds = append(cmds, goBackCmd)
194				}
195			}
196		}
197	case CopyMsg:
198		txt := msg.Text
199		if cfg := r.common.Config(); cfg != nil {
200			r.common.Output.Copy(txt)
201		}
202		r.statusbar.SetStatus("", msg.Message, "", "")
203	case ReadmeMsg:
204		cmds = append(cmds, r.updateTabComponent(&Readme{}, msg))
205	case FileItemsMsg, FileContentMsg:
206		cmds = append(cmds, r.updateTabComponent(&Files{}, msg))
207	case LogItemsMsg, LogDiffMsg, LogCountMsg:
208		cmds = append(cmds, r.updateTabComponent(&Log{}, msg))
209	case RefItemsMsg:
210		cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))
211	case StashListMsg, StashPatchMsg:
212		cmds = append(cmds, r.updateTabComponent(&Stash{}, msg))
213	// We have two spinners, one is used to when loading the repository and the
214	// other is used when loading the log.
215	// Check if the spinner ID matches the spinner model.
216	case spinner.TickMsg:
217		if r.state == loadingState && r.spinner.ID() == msg.ID {
218			s, cmd := r.spinner.Update(msg)
219			r.spinner = s
220			if cmd != nil {
221				cmds = append(cmds, cmd)
222			}
223		} else {
224			for i, c := range r.panes {
225				if c.SpinnerID() == msg.ID {
226					m, cmd := c.Update(msg)
227					r.panes[i] = m.(common.TabComponent)
228					if cmd != nil {
229						cmds = append(cmds, cmd)
230					}
231					break
232				}
233			}
234		}
235	case tea.WindowSizeMsg:
236		r.SetSize(msg.Width, msg.Height)
237		cmds = append(cmds, r.updateModels(msg))
238	case EmptyRepoMsg:
239		r.ref = nil
240		r.state = readyState
241		cmds = append(cmds, r.updateModels(msg))
242	case common.ErrorMsg:
243		r.state = readyState
244	case SwitchTabMsg:
245		for i, c := range r.panes {
246			if c.TabName() == msg.TabName() {
247				cmds = append(cmds, tabs.SelectTabCmd(i))
248				break
249			}
250		}
251	}
252	active := r.panes[r.activeTab]
253	m, cmd := active.Update(msg)
254	r.panes[r.activeTab] = m.(common.TabComponent)
255	if cmd != nil {
256		cmds = append(cmds, cmd)
257	}
258
259	// Update the status bar on these events
260	// Must come after we've updated the active tab
261	switch msg.(type) {
262	case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyMsg, tea.MouseMsg,
263		FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg,
264		LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg,
265		StashListMsg, StashPatchMsg:
266		r.setStatusBarInfo()
267	}
268
269	s, cmd := r.statusbar.Update(msg)
270	r.statusbar = s.(*statusbar.Model)
271	if cmd != nil {
272		cmds = append(cmds, cmd)
273	}
274
275	return r, tea.Batch(cmds...)
276}
277
278// View implements tea.Model.
279func (r *Repo) View() string {
280	wm, hm := r.getMargins()
281	hm += r.common.Styles.Tabs.GetHeight() +
282		r.common.Styles.Tabs.GetVerticalFrameSize()
283	s := r.common.Styles.Repo.Base.Copy().
284		Width(r.common.Width - wm).
285		Height(r.common.Height - hm)
286	mainStyle := r.common.Styles.Repo.Body.Copy().
287		Height(r.common.Height - hm)
288	var main string
289	var statusbar string
290	switch r.state {
291	case loadingState:
292		main = fmt.Sprintf("%s loading…", r.spinner.View())
293	case readyState:
294		main = r.panes[r.activeTab].View()
295		statusbar = r.statusbar.View()
296	}
297	main = r.common.Zone.Mark(
298		"repo-main",
299		mainStyle.Render(main),
300	)
301	view := lipgloss.JoinVertical(lipgloss.Top,
302		r.headerView(),
303		r.tabs.View(),
304		main,
305		statusbar,
306	)
307	return s.Render(view)
308}
309
310func (r *Repo) headerView() string {
311	if r.selectedRepo == nil {
312		return ""
313	}
314	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
315	header := r.selectedRepo.ProjectName()
316	if header == "" {
317		header = r.selectedRepo.Name()
318	}
319	header = r.common.Styles.Repo.HeaderName.Render(header)
320	desc := strings.TrimSpace(r.selectedRepo.Description())
321	if desc != "" {
322		header = lipgloss.JoinVertical(lipgloss.Top,
323			header,
324			r.common.Styles.Repo.HeaderDesc.Render(desc),
325		)
326	}
327	urlStyle := r.common.Styles.URLStyle.Copy().
328		Width(r.common.Width - lipgloss.Width(desc) - 1).
329		Align(lipgloss.Right)
330	var url string
331	if cfg := r.common.Config(); cfg != nil {
332		url = r.common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())
333	}
334	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
335	url = r.common.Zone.Mark(
336		fmt.Sprintf("%s-url", r.selectedRepo.Name()),
337		urlStyle.Render(url),
338	)
339
340	header = lipgloss.JoinHorizontal(lipgloss.Left, header, url)
341
342	style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
343	return style.Render(
344		truncate.Render(header),
345	)
346}
347
348func (r *Repo) setStatusBarInfo() {
349	if r.selectedRepo == nil {
350		return
351	}
352
353	active := r.panes[r.activeTab]
354	key := r.selectedRepo.Name()
355	value := active.StatusBarValue()
356	info := active.StatusBarInfo()
357	extra := "*"
358	if r.ref != nil {
359		extra += " " + r.ref.Name().Short()
360	}
361
362	r.statusbar.SetStatus(key, value, info, extra)
363}
364
365func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {
366	cmds := make([]tea.Cmd, 0)
367	for i, b := range r.panes {
368		if b.TabName() == c.TabName() {
369			m, cmd := b.Update(msg)
370			r.panes[i] = m.(common.TabComponent)
371			if cmd != nil {
372				cmds = append(cmds, cmd)
373			}
374			break
375		}
376	}
377	return tea.Batch(cmds...)
378}
379
380func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
381	cmds := make([]tea.Cmd, 0)
382	for i, b := range r.panes {
383		m, cmd := b.Update(msg)
384		r.panes[i] = m.(common.TabComponent)
385		if cmd != nil {
386			cmds = append(cmds, cmd)
387		}
388	}
389	return tea.Batch(cmds...)
390}
391
392func copyCmd(text, msg string) tea.Cmd {
393	return func() tea.Msg {
394		return CopyMsg{
395			Text:    text,
396			Message: msg,
397		}
398	}
399}
400
401func goBackCmd() tea.Msg {
402	return GoBackMsg{}
403}
404
405func switchTabCmd(m common.TabComponent) tea.Cmd {
406	return func() tea.Msg {
407		return SwitchTabMsg(m)
408	}
409}
410
411func renderLoading(c common.Common, s spinner.Model) string {
412	msg := fmt.Sprintf("%s loading…", s.View())
413	return c.Styles.SpinnerContainer.Copy().
414		Height(c.Height).
415		Render(msg)
416}