browse.go

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