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