repo.go

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