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