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 fmt.Errorf("failed to get absolute path: %w", 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		if err != nil {
 68			return fmt.Errorf("program execution failed: %w", err)
 69		}
 70		return nil
 71	},
 72}
 73
 74type state int
 75
 76const (
 77	startState state = iota
 78	errorState
 79)
 80
 81type model struct {
 82	model      *repo.Repo
 83	footer     *footer.Footer
 84	repo       proto.Repository
 85	common     common.Common
 86	state      state
 87	showFooter bool
 88	error      error
 89}
 90
 91var _ tea.Model = &model{}
 92
 93func (m *model) SetSize(w, h int) {
 94	m.common.SetSize(w, h)
 95	style := m.common.Styles.App
 96	wm := style.GetHorizontalFrameSize()
 97	hm := style.GetVerticalFrameSize()
 98	if m.showFooter {
 99		hm += m.footer.Height()
100	}
101
102	m.footer.SetSize(w-wm, h-hm)
103	m.model.SetSize(w-wm, h-hm)
104}
105
106// ShortHelp implements help.KeyMap.
107func (m model) ShortHelp() []key.Binding {
108	switch m.state {
109	case errorState:
110		return []key.Binding{
111			m.common.KeyMap.Back,
112			m.common.KeyMap.Quit,
113			m.common.KeyMap.Help,
114		}
115	case startState:
116		return m.model.ShortHelp()
117	default:
118		return m.model.ShortHelp()
119	}
120}
121
122// FullHelp implements help.KeyMap.
123func (m model) FullHelp() [][]key.Binding {
124	switch m.state {
125	case errorState:
126		return [][]key.Binding{
127			{
128				m.common.KeyMap.Back,
129			},
130			{
131				m.common.KeyMap.Quit,
132				m.common.KeyMap.Help,
133			},
134		}
135	case startState:
136		return m.model.FullHelp()
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.KeyPressMsg:
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.MouseClickMsg:
176		mouse := msg.Mouse()
177		switch mouse.Button {
178		case tea.MouseLeft:
179			switch {
180			case m.common.Zone.Get("footer").InBounds(msg):
181				cmds = append(cmds, footer.ToggleFooterCmd)
182			}
183		default:
184			// Handle other mouse buttons
185		}
186	case footer.ToggleFooterMsg:
187		m.footer.SetShowAll(!m.footer.ShowAll())
188		m.showFooter = !m.showFooter
189	case common.ErrorMsg:
190		m.error = msg
191		m.state = errorState
192		m.showFooter = true
193	}
194
195	f, cmd := m.footer.Update(msg)
196	m.footer = f.(*footer.Footer)
197	if cmd != nil {
198		cmds = append(cmds, cmd)
199	}
200
201	r, cmd := m.model.Update(msg)
202	m.model = r.(*repo.Repo)
203	if cmd != nil {
204		cmds = append(cmds, cmd)
205	}
206
207	// This fixes determining the height margin of the footer.
208	m.SetSize(m.common.Width, m.common.Height)
209
210	return m, tea.Batch(cmds...)
211}
212
213// View implements tea.Model.
214func (m *model) View() string {
215	style := m.common.Styles.App
216	wm, hm := style.GetHorizontalFrameSize(), style.GetVerticalFrameSize()
217	if m.showFooter {
218		hm += m.footer.Height()
219	}
220
221	var view string
222	switch m.state {
223	case startState:
224		view = m.model.View()
225	case errorState:
226		err := m.common.Styles.ErrorTitle.Render("Bummer")
227		err += m.common.Styles.ErrorBody.Render(m.error.Error())
228		view = m.common.Styles.Error.
229			Width(m.common.Width -
230				wm -
231				m.common.Styles.ErrorBody.GetHorizontalFrameSize()).
232			Height(m.common.Height -
233				hm -
234				m.common.Styles.Error.GetVerticalFrameSize()).
235			Render(err)
236	}
237
238	if m.showFooter {
239		view = lipgloss.JoinVertical(lipgloss.Left, view, m.footer.View())
240	}
241
242	return m.common.Zone.Scan(style.Render(view))
243}
244
245type repository struct {
246	r *git.Repository
247}
248
249var _ proto.Repository = repository{}
250
251// Description implements proto.Repository.
252func (r repository) Description() string {
253	return ""
254}
255
256// ID implements proto.Repository.
257func (r repository) ID() int64 {
258	return 0
259}
260
261// IsHidden implements proto.Repository.
262func (repository) IsHidden() bool {
263	return false
264}
265
266// IsMirror implements proto.Repository.
267func (repository) IsMirror() bool {
268	return false
269}
270
271// IsPrivate implements proto.Repository.
272func (repository) IsPrivate() bool {
273	return false
274}
275
276// Name implements proto.Repository.
277func (r repository) Name() string {
278	return filepath.Base(r.r.Path)
279}
280
281// Open implements proto.Repository.
282func (r repository) Open() (*git.Repository, error) {
283	return r.r, nil
284}
285
286// ProjectName implements proto.Repository.
287func (r repository) ProjectName() string {
288	return r.Name()
289}
290
291// UpdatedAt implements proto.Repository.
292func (r repository) UpdatedAt() time.Time {
293	t, err := r.r.LatestCommitTime()
294	if err != nil {
295		return time.Time{}
296	}
297
298	return t
299}
300
301// UserID implements proto.Repository.
302func (r repository) UserID() int64 {
303	return 0
304}
305
306// CreatedAt implements proto.Repository.
307func (r repository) CreatedAt() time.Time {
308	return time.Time{}
309}