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