1package main
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/server/proto"
13 "github.com/charmbracelet/soft-serve/server/ui/common"
14 "github.com/charmbracelet/soft-serve/server/ui/components/footer"
15 "github.com/charmbracelet/soft-serve/server/ui/pages/repo"
16 "github.com/muesli/termenv"
17 "github.com/spf13/cobra"
18)
19
20var browseCmd = &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 output := termenv.DefaultOutput()
43 ctx := cmd.Context()
44 c := common.NewCommon(ctx, output, 0, 0)
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
71func init() {
72 // HACK: This is a hack to hide the clone url
73 // TODO: Make this configurable
74 common.CloneCmd = func(publicURL, name string) string { return "" }
75 rootCmd.AddCommand(browseCmd)
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}