1package browse
  2
  3import (
  4	"fmt"
  5	"path/filepath"
  6	"time"
  7
  8	"github.com/charmbracelet/bubbles/key"
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/lipgloss"
 11	"github.com/charmbracelet/soft-serve/git"
 12	"github.com/charmbracelet/soft-serve/pkg/proto"
 13	"github.com/charmbracelet/soft-serve/pkg/ui/common"
 14	"github.com/charmbracelet/soft-serve/pkg/ui/components/footer"
 15	"github.com/charmbracelet/soft-serve/pkg/ui/pages/repo"
 16	"github.com/spf13/cobra"
 17)
 18
 19// Command is the browse command.
 20var Command = &cobra.Command{
 21	Use:   "browse PATH",
 22	Short: "Browse a repository",
 23	Args:  cobra.MaximumNArgs(1),
 24	RunE: func(cmd *cobra.Command, args []string) error {
 25		rp := "."
 26		if len(args) > 0 {
 27			rp = args[0]
 28		}
 29
 30		abs, err := filepath.Abs(rp)
 31		if err != nil {
 32			return err
 33		}
 34
 35		r, err := git.Open(abs)
 36		if err != nil {
 37			return fmt.Errorf("failed to open repository: %w", err)
 38		}
 39
 40		// Bubble Tea uses Termenv default output so we have to use the same
 41		// thing here.
 42		output := lipgloss.DefaultRenderer()
 43		ctx := cmd.Context()
 44		c := common.NewCommon(ctx, output, 0, 0)
 45		c.HideCloneCmd = true
 46		comps := []common.TabComponent{
 47			repo.NewReadme(c),
 48			repo.NewFiles(c),
 49			repo.NewLog(c),
 50		}
 51		if !r.IsBare {
 52			comps = append(comps, repo.NewStash(c))
 53		}
 54		comps = append(comps, repo.NewRefs(c, git.RefsHeads), repo.NewRefs(c, git.RefsTags))
 55		m := &model{
 56			model:  repo.New(c, comps...),
 57			repo:   repository{r},
 58			common: c,
 59		}
 60
 61		m.footer = footer.New(c, m)
 62		p := tea.NewProgram(m,
 63			tea.WithAltScreen(),
 64			tea.WithMouseCellMotion(),
 65		)
 66
 67		_, err = p.Run()
 68		return err
 69	},
 70}
 71
 72type state int
 73
 74const (
 75	startState state = iota
 76	errorState
 77)
 78
 79type model struct {
 80	model      *repo.Repo
 81	footer     *footer.Footer
 82	repo       proto.Repository
 83	common     common.Common
 84	state      state
 85	showFooter bool
 86	error      error
 87}
 88
 89var _ tea.Model = &model{}
 90
 91func (m *model) SetSize(w, h int) {
 92	m.common.SetSize(w, h)
 93	style := m.common.Styles.App
 94	wm := style.GetHorizontalFrameSize()
 95	hm := style.GetVerticalFrameSize()
 96	if m.showFooter {
 97		hm += m.footer.Height()
 98	}
 99
100	m.footer.SetSize(w-wm, h-hm)
101	m.model.SetSize(w-wm, h-hm)
102}
103
104// ShortHelp implements help.KeyMap.
105func (m model) ShortHelp() []key.Binding {
106	switch m.state {
107	case errorState:
108		return []key.Binding{
109			m.common.KeyMap.Back,
110			m.common.KeyMap.Quit,
111			m.common.KeyMap.Help,
112		}
113	default:
114		return m.model.ShortHelp()
115	}
116}
117
118// FullHelp implements help.KeyMap.
119func (m model) FullHelp() [][]key.Binding {
120	switch m.state {
121	case errorState:
122		return [][]key.Binding{
123			{
124				m.common.KeyMap.Back,
125			},
126			{
127				m.common.KeyMap.Quit,
128				m.common.KeyMap.Help,
129			},
130		}
131	default:
132		return m.model.FullHelp()
133	}
134}
135
136// Init implements tea.Model.
137func (m *model) Init() tea.Cmd {
138	return tea.Batch(
139		m.model.Init(),
140		m.footer.Init(),
141		func() tea.Msg {
142			return repo.RepoMsg(m.repo)
143		},
144		repo.UpdateRefCmd(m.repo),
145	)
146}
147
148// Update implements tea.Model.
149func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
150	m.common.Logger.Debugf("msg received: %T", msg)
151	cmds := make([]tea.Cmd, 0)
152	switch msg := msg.(type) {
153	case tea.WindowSizeMsg:
154		m.SetSize(msg.Width, msg.Height)
155	case tea.KeyMsg:
156		switch {
157		case key.Matches(msg, m.common.KeyMap.Back) && m.error != nil:
158			m.error = nil
159			m.state = startState
160			// Always show the footer on error.
161			m.showFooter = m.footer.ShowAll()
162		case key.Matches(msg, m.common.KeyMap.Help):
163			cmds = append(cmds, footer.ToggleFooterCmd)
164		case key.Matches(msg, m.common.KeyMap.Quit):
165			// Stop bubblezone background workers.
166			m.common.Zone.Close()
167			return m, tea.Quit
168		}
169	case tea.MouseMsg:
170		if msg.Action != tea.MouseActionPress {
171			break
172		}
173		switch msg.Button {
174		case tea.MouseButtonLeft:
175			switch {
176			case m.common.Zone.Get("footer").InBounds(msg):
177				cmds = append(cmds, footer.ToggleFooterCmd)
178			}
179		}
180	case footer.ToggleFooterMsg:
181		m.footer.SetShowAll(!m.footer.ShowAll())
182		m.showFooter = !m.showFooter
183	case common.ErrorMsg:
184		m.error = msg
185		m.state = errorState
186		m.showFooter = true
187	}
188
189	f, cmd := m.footer.Update(msg)
190	m.footer = f.(*footer.Footer)
191	if cmd != nil {
192		cmds = append(cmds, cmd)
193	}
194
195	r, cmd := m.model.Update(msg)
196	m.model = r.(*repo.Repo)
197	if cmd != nil {
198		cmds = append(cmds, cmd)
199	}
200
201	// This fixes determining the height margin of the footer.
202	m.SetSize(m.common.Width, m.common.Height)
203
204	return m, tea.Batch(cmds...)
205}
206
207// View implements tea.Model.
208func (m *model) View() string {
209	style := m.common.Styles.App
210	wm, hm := style.GetHorizontalFrameSize(), style.GetVerticalFrameSize()
211	if m.showFooter {
212		hm += m.footer.Height()
213	}
214
215	var view string
216	switch m.state {
217	case startState:
218		view = m.model.View()
219	case errorState:
220		err := m.common.Styles.ErrorTitle.Render("Bummer")
221		err += m.common.Styles.ErrorBody.Render(m.error.Error())
222		view = m.common.Styles.Error.
223			Width(m.common.Width -
224				wm -
225				m.common.Styles.ErrorBody.GetHorizontalFrameSize()).
226			Height(m.common.Height -
227				hm -
228				m.common.Styles.Error.GetVerticalFrameSize()).
229			Render(err)
230	}
231
232	if m.showFooter {
233		view = lipgloss.JoinVertical(lipgloss.Left, view, m.footer.View())
234	}
235
236	return m.common.Zone.Scan(style.Render(view))
237}
238
239type repository struct {
240	r *git.Repository
241}
242
243var _ proto.Repository = repository{}
244
245// Description implements proto.Repository.
246func (r repository) Description() string {
247	return ""
248}
249
250// ID implements proto.Repository.
251func (r repository) ID() int64 {
252	return 0
253}
254
255// IsHidden implements proto.Repository.
256func (repository) IsHidden() bool {
257	return false
258}
259
260// IsMirror implements proto.Repository.
261func (repository) IsMirror() bool {
262	return false
263}
264
265// IsPrivate implements proto.Repository.
266func (repository) IsPrivate() bool {
267	return false
268}
269
270// Name implements proto.Repository.
271func (r repository) Name() string {
272	return filepath.Base(r.r.Path)
273}
274
275// Open implements proto.Repository.
276func (r repository) Open() (*git.Repository, error) {
277	return r.r, nil
278}
279
280// ProjectName implements proto.Repository.
281func (r repository) ProjectName() string {
282	return r.Name()
283}
284
285// UpdatedAt implements proto.Repository.
286func (r repository) UpdatedAt() time.Time {
287	t, err := r.r.LatestCommitTime()
288	if err != nil {
289		return time.Time{}
290	}
291
292	return t
293}
294
295// UserID implements proto.Repository.
296func (r repository) UserID() int64 {
297	return 0
298}
299
300// CreatedAt implements proto.Repository.
301func (r repository) CreatedAt() time.Time {
302	return time.Time{}
303}