browse.go

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