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
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.KeyMsg, tea.MouseMsg:
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.MouseMsg:
189			if msg.Action != tea.MouseActionPress {
190				break
191			}
192			switch msg.Button {
193			case tea.MouseButtonLeft:
194				switch {
195				case r.common.Zone.Get("repo-help").InBounds(msg):
196					cmds = append(cmds, footer.ToggleFooterCmd)
197				}
198			case tea.MouseButtonRight:
199				switch {
200				case r.common.Zone.Get("repo-main").InBounds(msg):
201					cmds = append(cmds, goBackCmd)
202				}
203			}
204		}
205		switch msg := msg.(type) {
206		case tea.KeyMsg:
207			switch {
208			case key.Matches(msg, r.common.KeyMap.Back):
209				cmds = append(cmds, goBackCmd)
210			}
211		}
212	case CopyMsg:
213		txt := msg.Text
214		if cfg := r.common.Config(); cfg != nil {
215			r.common.Output.Copy(txt)
216		}
217		r.statusbar.SetStatus("", msg.Message, "", "")
218	case ReadmeMsg:
219		cmds = append(cmds, r.updateTabComponent(&Readme{}, msg))
220	case FileItemsMsg, FileContentMsg:
221		cmds = append(cmds, r.updateTabComponent(&Files{}, msg))
222	case LogItemsMsg, LogDiffMsg, LogCountMsg:
223		cmds = append(cmds, r.updateTabComponent(&Log{}, msg))
224	case RefItemsMsg:
225		cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))
226	case StashListMsg, StashPatchMsg:
227		cmds = append(cmds, r.updateTabComponent(&Stash{}, msg))
228	// We have two spinners, one is used to when loading the repository and the
229	// other is used when loading the log.
230	// Check if the spinner ID matches the spinner model.
231	case spinner.TickMsg:
232		if r.state == loadingState && r.spinner.ID() == msg.ID {
233			s, cmd := r.spinner.Update(msg)
234			r.spinner = s
235			if cmd != nil {
236				cmds = append(cmds, cmd)
237			}
238		} else {
239			for i, c := range r.panes {
240				if c.SpinnerID() == msg.ID {
241					m, cmd := c.Update(msg)
242					r.panes[i] = m.(common.TabComponent)
243					if cmd != nil {
244						cmds = append(cmds, cmd)
245					}
246					break
247				}
248			}
249		}
250	case tea.WindowSizeMsg:
251		r.SetSize(msg.Width, msg.Height)
252		cmds = append(cmds, r.updateModels(msg))
253	case EmptyRepoMsg:
254		r.ref = nil
255		r.state = readyState
256		cmds = append(cmds, r.updateModels(msg))
257	case common.ErrorMsg:
258		r.state = readyState
259	case SwitchTabMsg:
260		for i, c := range r.panes {
261			if c.TabName() == msg.TabName() {
262				cmds = append(cmds, tabs.SelectTabCmd(i))
263				break
264			}
265		}
266	}
267	active := r.panes[r.activeTab]
268	m, cmd := active.Update(msg)
269	r.panes[r.activeTab] = m.(common.TabComponent)
270	if cmd != nil {
271		cmds = append(cmds, cmd)
272	}
273
274	// Update the status bar on these events
275	// Must come after we've updated the active tab
276	switch msg.(type) {
277	case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyMsg, tea.MouseMsg,
278		FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg,
279		LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg,
280		StashListMsg, StashPatchMsg:
281		r.setStatusBarInfo()
282	}
283
284	s, cmd := r.statusbar.Update(msg)
285	r.statusbar = s.(*statusbar.Model)
286	if cmd != nil {
287		cmds = append(cmds, cmd)
288	}
289
290	return r, tea.Batch(cmds...)
291}
292
293// View implements tea.Model.
294func (r *Repo) View() string {
295	wm, hm := r.getMargins()
296	hm += r.common.Styles.Tabs.GetHeight() +
297		r.common.Styles.Tabs.GetVerticalFrameSize()
298	s := r.common.Styles.Repo.Base.
299		Width(r.common.Width - wm).
300		Height(r.common.Height - hm)
301	mainStyle := r.common.Styles.Repo.Body.
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.Left,
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 := r.common.Renderer.NewStyle().MaxWidth(r.common.Width)
330	header := r.selectedRepo.ProjectName()
331	if header == "" {
332		header = r.selectedRepo.Name()
333	}
334	header = r.common.Styles.Repo.HeaderName.Render(header)
335	desc := strings.TrimSpace(r.selectedRepo.Description())
336	if desc != "" {
337		header = lipgloss.JoinVertical(lipgloss.Left,
338			header,
339			r.common.Styles.Repo.HeaderDesc.Render(desc),
340		)
341	}
342	urlStyle := r.common.Styles.URLStyle.
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 = r.common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())
348	}
349	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
350	url = r.common.Zone.Mark(
351		fmt.Sprintf("%s-url", r.selectedRepo.Name()),
352		urlStyle.Render(url),
353	)
354
355	header = lipgloss.JoinHorizontal(lipgloss.Top, header, url)
356
357	style := r.common.Styles.Repo.Header.Width(r.common.Width)
358	return style.Render(
359		truncate.Render(header),
360	)
361}
362
363func (r *Repo) setStatusBarInfo() {
364	if r.selectedRepo == nil {
365		return
366	}
367
368	active := r.panes[r.activeTab]
369	key := r.selectedRepo.Name()
370	value := active.StatusBarValue()
371	info := active.StatusBarInfo()
372	extra := "*"
373	if r.ref != nil {
374		extra += " " + r.ref.Name().Short()
375	}
376
377	r.statusbar.SetStatus(key, value, info, extra)
378}
379
380func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {
381	cmds := make([]tea.Cmd, 0)
382	for i, b := range r.panes {
383		if b.TabName() == c.TabName() {
384			m, cmd := b.Update(msg)
385			r.panes[i] = m.(common.TabComponent)
386			if cmd != nil {
387				cmds = append(cmds, cmd)
388			}
389			break
390		}
391	}
392	return tea.Batch(cmds...)
393}
394
395func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
396	cmds := make([]tea.Cmd, 0)
397	for i, b := range r.panes {
398		m, cmd := b.Update(msg)
399		r.panes[i] = m.(common.TabComponent)
400		if cmd != nil {
401			cmds = append(cmds, cmd)
402		}
403	}
404	return tea.Batch(cmds...)
405}
406
407func copyCmd(text, msg string) tea.Cmd {
408	return func() tea.Msg {
409		return CopyMsg{
410			Text:    text,
411			Message: msg,
412		}
413	}
414}
415
416func goBackCmd() tea.Msg {
417	return GoBackMsg{}
418}
419
420func switchTabCmd(m common.TabComponent) tea.Cmd {
421	return func() tea.Msg {
422		return SwitchTabMsg(m)
423	}
424}
425
426func renderLoading(c common.Common, s spinner.Model) string {
427	msg := fmt.Sprintf("%s loading…", s.View())
428	return c.Styles.SpinnerContainer.
429		Height(c.Height).
430		Render(msg)
431}