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/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
107// Path returns the current component path.
108func (r *Repo) Path() string {
109	return r.panes[r.activeTab].Path()
110}
111
112func (r *Repo) commonHelp() []key.Binding {
113	b := make([]key.Binding, 0)
114	back := r.common.KeyMap.Back
115	back.SetHelp("esc", "back to menu")
116	tab := r.common.KeyMap.Section
117	tab.SetHelp("tab", "switch tab")
118	b = append(b, back)
119	b = append(b, tab)
120	return b
121}
122
123// ShortHelp implements help.KeyMap.
124func (r *Repo) ShortHelp() []key.Binding {
125	b := r.commonHelp()
126	b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
127	return b
128}
129
130// FullHelp implements help.KeyMap.
131func (r *Repo) FullHelp() [][]key.Binding {
132	b := make([][]key.Binding, 0)
133	b = append(b, r.commonHelp())
134	b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
135	return b
136}
137
138// Init implements tea.View.
139func (r *Repo) Init() tea.Cmd {
140	r.state = loadingState
141	r.activeTab = 0
142	return tea.Batch(
143		r.tabs.Init(),
144		r.statusbar.Init(),
145		r.spinner.Tick,
146	)
147}
148
149// Update implements tea.Model.
150func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151	cmds := make([]tea.Cmd, 0)
152	switch msg := msg.(type) {
153	case RepoMsg:
154		// Set the state to loading when we get a new repository.
155		r.selectedRepo = msg
156		cmds = append(cmds,
157			r.Init(),
158			// This will set the selected repo in each pane's model.
159			r.updateModels(msg),
160		)
161	case RefMsg:
162		r.ref = msg
163		cmds = append(cmds, r.updateModels(msg))
164		r.state = readyState
165	case tabs.SelectTabMsg:
166		r.activeTab = int(msg)
167		t, cmd := r.tabs.Update(msg)
168		r.tabs = t.(*tabs.Tabs)
169		if cmd != nil {
170			cmds = append(cmds, cmd)
171		}
172	case tabs.ActiveTabMsg:
173		r.activeTab = int(msg)
174	case tea.KeyPressMsg, tea.MouseClickMsg:
175		t, cmd := r.tabs.Update(msg)
176		r.tabs = t.(*tabs.Tabs)
177		if cmd != nil {
178			cmds = append(cmds, cmd)
179		}
180		if r.selectedRepo != nil {
181			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
182			cmd := r.common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())
183			if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
184				cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard"))
185			}
186		}
187		switch msg := msg.(type) {
188		case tea.MouseClickMsg:
189			switch msg.Button {
190			case tea.MouseLeft:
191				switch {
192				case r.common.Zone.Get("repo-help").InBounds(msg):
193					cmds = append(cmds, footer.ToggleFooterCmd)
194				}
195			case tea.MouseRight:
196				switch {
197				case r.common.Zone.Get("repo-main").InBounds(msg):
198					cmds = append(cmds, goBackCmd)
199				}
200			}
201		}
202		switch msg := msg.(type) {
203		case tea.KeyPressMsg:
204			switch {
205			case key.Matches(msg, r.common.KeyMap.Back):
206				cmds = append(cmds, goBackCmd)
207			}
208		}
209	case CopyMsg:
210		txt := msg.Text
211		if cfg := r.common.Config(); cfg != nil {
212			cmds = append(cmds, tea.SetClipboard(txt))
213		}
214		r.statusbar.SetStatus("", msg.Message, "", "")
215	case ReadmeMsg:
216		cmds = append(cmds, r.updateTabComponent(&Readme{}, msg))
217	case FileItemsMsg, FileContentMsg:
218		cmds = append(cmds, r.updateTabComponent(&Files{}, msg))
219	case LogItemsMsg, LogDiffMsg, LogCountMsg:
220		cmds = append(cmds, r.updateTabComponent(&Log{}, msg))
221	case RefItemsMsg:
222		cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))
223	case StashListMsg, StashPatchMsg:
224		cmds = append(cmds, r.updateTabComponent(&Stash{}, msg))
225	// We have two spinners, one is used to when loading the repository and the
226	// other is used when loading the log.
227	// Check if the spinner ID matches the spinner model.
228	case spinner.TickMsg:
229		//nolint:nestif // Complex UI state management requires nested conditions
230		if r.state == loadingState && r.spinner.ID() == msg.ID {
231			s, cmd := r.spinner.Update(msg)
232			r.spinner = s
233			if cmd != nil {
234				cmds = append(cmds, cmd)
235			}
236		} else {
237			for i, c := range r.panes {
238				if c.SpinnerID() == msg.ID {
239					m, cmd := c.Update(msg)
240					r.panes[i] = m.(common.TabComponent)
241					if cmd != nil {
242						cmds = append(cmds, cmd)
243					}
244					break
245				}
246			}
247		}
248	case tea.WindowSizeMsg:
249		r.SetSize(msg.Width, msg.Height)
250		cmds = append(cmds, r.updateModels(msg))
251	case EmptyRepoMsg:
252		r.ref = nil
253		r.state = readyState
254		cmds = append(cmds, r.updateModels(msg))
255	case common.ErrorMsg:
256		r.state = readyState
257	case SwitchTabMsg:
258		for i, c := range r.panes {
259			if c.TabName() == msg.TabName() {
260				cmds = append(cmds, tabs.SelectTabCmd(i))
261				break
262			}
263		}
264	}
265	active := r.panes[r.activeTab]
266	m, cmd := active.Update(msg)
267	r.panes[r.activeTab] = m.(common.TabComponent)
268	if cmd != nil {
269		cmds = append(cmds, cmd)
270	}
271
272	// Update the status bar on these events
273	// Must come after we've updated the active tab
274	switch msg.(type) {
275	case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyPressMsg,
276		tea.MouseClickMsg, tea.MouseWheelMsg, FileItemsMsg, FileContentMsg,
277		FileBlameMsg, selector.ActiveMsg, LogItemsMsg, GoBackMsg, LogDiffMsg,
278		EmptyRepoMsg, StashListMsg, StashPatchMsg:
279		r.setStatusBarInfo()
280	}
281
282	s, cmd := r.statusbar.Update(msg)
283	r.statusbar = s.(*statusbar.Model)
284	if cmd != nil {
285		cmds = append(cmds, cmd)
286	}
287
288	return r, tea.Batch(cmds...)
289}
290
291// View implements tea.Model.
292func (r *Repo) View() string {
293	wm, hm := r.getMargins()
294	hm += r.common.Styles.Tabs.GetHeight() +
295		r.common.Styles.Tabs.GetVerticalFrameSize()
296	s := r.common.Styles.Repo.Base.
297		Width(r.common.Width - wm).
298		Height(r.common.Height - hm)
299	mainStyle := r.common.Styles.Repo.Body.
300		Height(r.common.Height - hm)
301	var main string
302	var statusbar string
303	switch r.state {
304	case loadingState:
305		main = fmt.Sprintf("%s loading…", r.spinner.View())
306	case readyState:
307		main = r.panes[r.activeTab].View()
308		statusbar = r.statusbar.View()
309	}
310	main = r.common.Zone.Mark(
311		"repo-main",
312		mainStyle.Render(main),
313	)
314	view := lipgloss.JoinVertical(lipgloss.Left,
315		r.headerView(),
316		r.tabs.View(),
317		main,
318		statusbar,
319	)
320	return s.Render(view)
321}
322
323func (r *Repo) headerView() string {
324	if r.selectedRepo == nil {
325		return ""
326	}
327	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
328	header := r.selectedRepo.ProjectName()
329	if header == "" {
330		header = r.selectedRepo.Name()
331	}
332	header = r.common.Styles.Repo.HeaderName.Render(header)
333	desc := strings.TrimSpace(r.selectedRepo.Description())
334	if desc != "" {
335		header = lipgloss.JoinVertical(lipgloss.Left,
336			header,
337			r.common.Styles.Repo.HeaderDesc.Render(desc),
338		)
339	}
340	urlStyle := r.common.Styles.URLStyle.
341		Width(r.common.Width - lipgloss.Width(header) - 1).
342		Align(lipgloss.Right)
343	var url string
344	if cfg := r.common.Config(); cfg != nil {
345		url = r.common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())
346	}
347	url = common.TruncateString(url, r.common.Width-lipgloss.Width(header)-1)
348	url = r.common.Zone.Mark(
349		fmt.Sprintf("%s-url", r.selectedRepo.Name()),
350		urlStyle.Render(url),
351	)
352
353	header = lipgloss.JoinHorizontal(lipgloss.Top, header, url)
354
355	style := r.common.Styles.Repo.Header.Width(r.common.Width)
356	return style.Render(
357		truncate.Render(header),
358	)
359}
360
361func (r *Repo) setStatusBarInfo() {
362	if r.selectedRepo == nil {
363		return
364	}
365
366	active := r.panes[r.activeTab]
367	key := r.selectedRepo.Name()
368	value := active.StatusBarValue()
369	info := active.StatusBarInfo()
370	extra := "*"
371	if r.ref != nil {
372		extra += " " + r.ref.Name().Short()
373	}
374
375	r.statusbar.SetStatus(key, value, info, extra)
376}
377
378func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {
379	cmds := make([]tea.Cmd, 0)
380	for i, b := range r.panes {
381		if b.TabName() == c.TabName() {
382			m, cmd := b.Update(msg)
383			r.panes[i] = m.(common.TabComponent)
384			if cmd != nil {
385				cmds = append(cmds, cmd)
386			}
387			break
388		}
389	}
390	return tea.Batch(cmds...)
391}
392
393func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
394	cmds := make([]tea.Cmd, 0)
395	for i, b := range r.panes {
396		m, cmd := b.Update(msg)
397		r.panes[i] = m.(common.TabComponent)
398		if cmd != nil {
399			cmds = append(cmds, cmd)
400		}
401	}
402	return tea.Batch(cmds...)
403}
404
405func copyCmd(text, msg string) tea.Cmd {
406	return func() tea.Msg {
407		return CopyMsg{
408			Text:    text,
409			Message: msg,
410		}
411	}
412}
413
414func goBackCmd() tea.Msg {
415	return GoBackMsg{}
416}
417
418func switchTabCmd(m common.TabComponent) tea.Cmd {
419	return func() tea.Msg {
420		return SwitchTabMsg(m)
421	}
422}
423
424func renderLoading(c common.Common, s spinner.Model) string {
425	msg := fmt.Sprintf("%s loading…", s.View())
426	return c.Styles.SpinnerContainer.
427		Height(c.Height).
428		Render(msg)
429}