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