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}