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