1package repo
2
3import (
4 "errors"
5 "fmt"
6 "path/filepath"
7 "strings"
8
9 gitm "github.com/aymanbagabas/git-module"
10 "github.com/charmbracelet/bubbles/v2/key"
11 "github.com/charmbracelet/bubbles/v2/spinner"
12 tea "github.com/charmbracelet/bubbletea/v2"
13 "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/pkg/proto"
15 "github.com/charmbracelet/soft-serve/pkg/ui/common"
16 "github.com/charmbracelet/soft-serve/pkg/ui/components/code"
17 "github.com/charmbracelet/soft-serve/pkg/ui/components/selector"
18)
19
20type filesView int
21
22const (
23 filesViewLoading filesView = iota
24 filesViewFiles
25 filesViewContent
26)
27
28var (
29 errNoFileSelected = errors.New("no file selected")
30 errBinaryFile = errors.New("binary file")
31 errInvalidFile = errors.New("invalid file")
32)
33
34var (
35 lineNo = key.NewBinding(
36 key.WithKeys("l"),
37 key.WithHelp("l", "toggle line numbers"),
38 )
39 blameView = key.NewBinding(
40 key.WithKeys("b"),
41 key.WithHelp("b", "toggle blame view"),
42 )
43 preview = key.NewBinding(
44 key.WithKeys("p"),
45 key.WithHelp("p", "toggle preview"),
46 )
47)
48
49// FileItemsMsg is a message that contains a list of files.
50type FileItemsMsg []selector.IdentifiableItem
51
52// FileContentMsg is a message that contains the content of a file.
53type FileContentMsg struct {
54 content string
55 ext string
56}
57
58// FileBlameMsg is a message that contains the blame of a file.
59type FileBlameMsg *gitm.Blame
60
61// Files is the model for the files view.
62type Files struct {
63 common common.Common
64 selector *selector.Selector
65 ref *git.Reference
66 activeView filesView
67 repo proto.Repository
68 code *code.Code
69 path string
70 currentItem *FileItem
71 currentContent FileContentMsg
72 currentBlame FileBlameMsg
73 lastSelected []int
74 lineNumber bool
75 spinner spinner.Model
76 cursor int
77 blameView bool
78}
79
80// NewFiles creates a new files model.
81func NewFiles(common common.Common) *Files {
82 f := &Files{
83 common: common,
84 code: code.New(common, "", ""),
85 activeView: filesViewLoading,
86 lastSelected: make([]int, 0),
87 lineNumber: true,
88 }
89 selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common})
90 selector.SetShowFilter(false)
91 selector.SetShowHelp(false)
92 selector.SetShowPagination(false)
93 selector.SetShowStatusBar(false)
94 selector.SetShowTitle(false)
95 selector.SetFilteringEnabled(false)
96 selector.DisableQuitKeybindings()
97 selector.KeyMap.NextPage = common.KeyMap.NextPage
98 selector.KeyMap.PrevPage = common.KeyMap.PrevPage
99 f.selector = selector
100 f.code.ShowLineNumber = f.lineNumber
101 s := spinner.New(spinner.WithSpinner(spinner.Dot),
102 spinner.WithStyle(common.Styles.Spinner))
103 f.spinner = s
104 return f
105}
106
107// Path implements common.TabComponent.
108func (f *Files) Path() string {
109 path := f.path
110 if path == "." {
111 return ""
112 }
113 return path
114}
115
116// TabName returns the tab name.
117func (f *Files) TabName() string {
118 return "Files"
119}
120
121// SetSize implements common.Component.
122func (f *Files) SetSize(width, height int) {
123 f.common.SetSize(width, height)
124 f.selector.SetSize(width, height)
125 f.code.SetSize(width, height)
126}
127
128// ShortHelp implements help.KeyMap.
129func (f *Files) ShortHelp() []key.Binding {
130 k := f.selector.KeyMap
131 switch f.activeView { //nolint:exhaustive
132 case filesViewFiles:
133 return []key.Binding{
134 f.common.KeyMap.SelectItem,
135 f.common.KeyMap.BackItem,
136 k.CursorUp,
137 k.CursorDown,
138 }
139 case filesViewContent:
140 b := []key.Binding{
141 f.common.KeyMap.UpDown,
142 f.common.KeyMap.BackItem,
143 }
144 return b
145 default:
146 return []key.Binding{}
147 }
148}
149
150// FullHelp implements help.KeyMap.
151func (f *Files) FullHelp() [][]key.Binding {
152 b := make([][]key.Binding, 0)
153 copyKey := f.common.KeyMap.Copy
154 actionKeys := []key.Binding{}
155 switch f.activeView { //nolint:exhaustive
156 case filesViewFiles:
157 copyKey.SetHelp("c", "copy name")
158 k := f.selector.KeyMap
159 b = append(b, [][]key.Binding{
160 {
161 f.common.KeyMap.SelectItem,
162 f.common.KeyMap.BackItem,
163 },
164 {
165 k.CursorUp,
166 k.CursorDown,
167 k.NextPage,
168 k.PrevPage,
169 },
170 {
171 k.GoToStart,
172 k.GoToEnd,
173 },
174 }...)
175 case filesViewContent:
176 if !f.code.UseGlamour {
177 actionKeys = append(actionKeys, lineNo)
178 }
179 actionKeys = append(actionKeys, blameView)
180 if common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) &&
181 !f.blameView {
182 actionKeys = append(actionKeys, preview)
183 }
184 copyKey.SetHelp("c", "copy content")
185 k := f.code.KeyMap
186 b = append(b, []key.Binding{
187 f.common.KeyMap.BackItem,
188 })
189 b = append(b, [][]key.Binding{
190 {
191 k.PageDown,
192 k.PageUp,
193 k.HalfPageDown,
194 k.HalfPageUp,
195 },
196 {
197 k.Down,
198 k.Up,
199 f.common.KeyMap.GotoTop,
200 f.common.KeyMap.GotoBottom,
201 },
202 }...)
203 }
204 actionKeys = append([]key.Binding{
205 copyKey,
206 }, actionKeys...)
207 return append(b, actionKeys)
208}
209
210// Init implements tea.Model.
211func (f *Files) Init() tea.Cmd {
212 f.path = ""
213 f.currentItem = nil
214 f.activeView = filesViewLoading
215 f.lastSelected = make([]int, 0)
216 f.blameView = false
217 f.currentBlame = nil
218 f.code.UseGlamour = false
219 return tea.Batch(f.spinner.Tick, f.updateFilesCmd)
220}
221
222// Update implements tea.Model.
223func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
224 cmds := make([]tea.Cmd, 0)
225 switch msg := msg.(type) {
226 case RepoMsg:
227 f.repo = msg
228 case RefMsg:
229 f.ref = msg
230 f.selector.Select(0)
231 cmds = append(cmds, f.Init())
232 case FileItemsMsg:
233 cmds = append(cmds,
234 f.selector.SetItems(msg),
235 )
236 f.activeView = filesViewFiles
237 if f.cursor >= 0 {
238 f.selector.Select(f.cursor)
239 f.cursor = -1
240 }
241 case FileContentMsg:
242 f.activeView = filesViewContent
243 f.currentContent = msg
244 f.code.UseGlamour = common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext)
245 cmds = append(cmds, f.code.SetContent(msg.content, msg.ext))
246 f.code.GotoTop()
247 case FileBlameMsg:
248 f.currentBlame = msg
249 f.activeView = filesViewContent
250 f.code.UseGlamour = false
251 f.code.SetSideNote(renderBlame(f.common, f.currentItem, msg))
252 case selector.SelectMsg:
253 switch sel := msg.IdentifiableItem.(type) {
254 case FileItem:
255 f.currentItem = &sel
256 f.path = filepath.Join(f.path, sel.entry.Name())
257 if sel.entry.IsTree() {
258 cmds = append(cmds, f.selectTreeCmd)
259 } else {
260 cmds = append(cmds, f.selectFileCmd)
261 }
262 }
263 case GoBackMsg:
264 switch f.activeView { //nolint:exhaustive
265 case filesViewFiles, filesViewContent:
266 cmds = append(cmds, f.deselectItemCmd())
267 }
268 case tea.KeyPressMsg:
269 switch f.activeView { //nolint:exhaustive
270 case filesViewFiles:
271 switch {
272 case key.Matches(msg, f.common.KeyMap.SelectItem):
273 cmds = append(cmds, f.selector.SelectItemCmd)
274 case key.Matches(msg, f.common.KeyMap.BackItem):
275 cmds = append(cmds, f.deselectItemCmd())
276 }
277 case filesViewContent:
278 switch {
279 case key.Matches(msg, f.common.KeyMap.BackItem):
280 cmds = append(cmds, f.deselectItemCmd())
281 case key.Matches(msg, f.common.KeyMap.Copy):
282 cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))
283 case key.Matches(msg, lineNo) && !f.code.UseGlamour:
284 f.lineNumber = !f.lineNumber
285 f.code.ShowLineNumber = f.lineNumber
286 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
287 case key.Matches(msg, blameView):
288 f.activeView = filesViewLoading
289 f.blameView = !f.blameView
290 if f.blameView {
291 cmds = append(cmds, f.fetchBlame)
292 } else {
293 f.activeView = filesViewContent
294 cmds = append(cmds, f.code.SetSideNote(""))
295 }
296 cmds = append(cmds, f.spinner.Tick)
297 case key.Matches(msg, preview) &&
298 common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) && !f.blameView:
299 f.code.UseGlamour = !f.code.UseGlamour
300 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
301 }
302 }
303 case tea.WindowSizeMsg:
304 f.SetSize(msg.Width, msg.Height)
305 switch f.activeView { //nolint:exhaustive
306 case filesViewFiles:
307 if f.repo != nil {
308 cmds = append(cmds, f.updateFilesCmd)
309 }
310 case filesViewContent:
311 if f.currentContent.content != "" {
312 m, cmd := f.code.Update(msg)
313 f.code = m.(*code.Code)
314 if cmd != nil {
315 cmds = append(cmds, cmd)
316 }
317 }
318 }
319 case EmptyRepoMsg:
320 f.ref = nil
321 f.path = ""
322 f.currentItem = nil
323 f.activeView = filesViewFiles
324 f.lastSelected = make([]int, 0)
325 f.selector.Select(0)
326 cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
327 case spinner.TickMsg:
328 if f.activeView == filesViewLoading && f.spinner.ID() == msg.ID {
329 s, cmd := f.spinner.Update(msg)
330 f.spinner = s
331 if cmd != nil {
332 cmds = append(cmds, cmd)
333 }
334 }
335 }
336 switch f.activeView { //nolint:exhaustive
337 case filesViewFiles:
338 m, cmd := f.selector.Update(msg)
339 f.selector = m.(*selector.Selector)
340 if cmd != nil {
341 cmds = append(cmds, cmd)
342 }
343 case filesViewContent:
344 m, cmd := f.code.Update(msg)
345 f.code = m.(*code.Code)
346 if cmd != nil {
347 cmds = append(cmds, cmd)
348 }
349 }
350 return f, tea.Batch(cmds...)
351}
352
353// View implements tea.Model.
354func (f *Files) View() string {
355 switch f.activeView {
356 case filesViewLoading:
357 return renderLoading(f.common, f.spinner)
358 case filesViewFiles:
359 return f.selector.View()
360 case filesViewContent:
361 return f.code.View()
362 default:
363 return ""
364 }
365}
366
367// SpinnerID implements common.TabComponent.
368func (f *Files) SpinnerID() int {
369 return f.spinner.ID()
370}
371
372// StatusBarValue returns the status bar value.
373func (f *Files) StatusBarValue() string {
374 p := f.path
375 if p == "." || p == "" {
376 return " "
377 }
378 return p
379}
380
381// StatusBarInfo returns the status bar info.
382func (f *Files) StatusBarInfo() string {
383 switch f.activeView { //nolint:exhaustive
384 case filesViewFiles:
385 return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
386 case filesViewContent:
387 return common.ScrollPercent(f.code.ScrollPosition())
388 default:
389 return ""
390 }
391}
392
393func (f *Files) updateFilesCmd() tea.Msg {
394 files := make([]selector.IdentifiableItem, 0)
395 dirs := make([]selector.IdentifiableItem, 0)
396 if f.ref == nil {
397 return nil
398 }
399 r, err := f.repo.Open()
400 if err != nil {
401 return common.ErrorCmd(err)
402 }
403 path := f.path
404 ref := f.ref
405 t, err := r.TreePath(ref, path)
406 if err != nil {
407 return common.ErrorCmd(err)
408 }
409 ents, err := t.Entries()
410 if err != nil {
411 return common.ErrorCmd(err)
412 }
413 ents.Sort()
414 for _, e := range ents {
415 if e.IsTree() {
416 dirs = append(dirs, FileItem{entry: e})
417 } else {
418 files = append(files, FileItem{entry: e})
419 }
420 }
421 return FileItemsMsg(append(dirs, files...))
422}
423
424func (f *Files) selectTreeCmd() tea.Msg {
425 if f.currentItem != nil && f.currentItem.entry.IsTree() {
426 f.lastSelected = append(f.lastSelected, f.selector.Index())
427 f.cursor = 0
428 return f.updateFilesCmd()
429 }
430 return common.ErrorMsg(errNoFileSelected)
431}
432
433func (f *Files) selectFileCmd() tea.Msg {
434 i := f.currentItem
435 if i != nil && !i.entry.IsTree() { //nolint:nestif
436 fi := i.entry.File()
437 if i.Mode().IsDir() || f == nil {
438 return common.ErrorMsg(errInvalidFile)
439 }
440
441 var err error
442 var bin bool
443
444 r, err := f.repo.Open()
445 if err == nil {
446 attrs, err := r.CheckAttributes(f.ref, fi.Path())
447 if err == nil {
448 for _, attr := range attrs {
449 if (attr.Name == "binary" && attr.Value == "set") ||
450 (attr.Name == "text" && attr.Value == "unset") {
451 bin = true
452 break
453 }
454 }
455 }
456 }
457
458 if !bin {
459 bin, err = fi.IsBinary()
460 if err != nil {
461 f.path = filepath.Dir(f.path)
462 return common.ErrorMsg(err)
463 }
464 }
465
466 if bin {
467 f.path = filepath.Dir(f.path)
468 return common.ErrorMsg(errBinaryFile)
469 }
470
471 c, err := fi.Bytes()
472 if err != nil {
473 f.path = filepath.Dir(f.path)
474 return common.ErrorMsg(err)
475 }
476
477 f.lastSelected = append(f.lastSelected, f.selector.Index())
478 return FileContentMsg{string(c), i.entry.Name()}
479 }
480
481 return common.ErrorMsg(errNoFileSelected)
482}
483
484func (f *Files) fetchBlame() tea.Msg {
485 r, err := f.repo.Open()
486 if err != nil {
487 return common.ErrorMsg(err)
488 }
489
490 b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path())
491 if err != nil {
492 return common.ErrorMsg(err)
493 }
494
495 return FileBlameMsg(b)
496}
497
498func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string {
499 if f == nil || f.entry.IsTree() || b == nil {
500 return ""
501 }
502
503 lines := make([]string, 0)
504 i := 1
505 var prev string
506 for {
507 commit := b.Line(i)
508 if commit == nil {
509 break
510 }
511 who := fmt.Sprintf("%s <%s>", commit.Author.Name, commit.Author.Email)
512 line := fmt.Sprintf("%s %s %s",
513 c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]),
514 c.Styles.Tree.Blame.Message.Render(commit.Summary()),
515 c.Styles.Tree.Blame.Who.Render(who),
516 )
517 if line != prev {
518 lines = append(lines, line)
519 } else {
520 lines = append(lines, "")
521 }
522 prev = line
523 i++
524 }
525
526 return strings.Join(lines, "\n")
527}
528
529func (f *Files) deselectItemCmd() tea.Cmd {
530 f.path = filepath.Dir(f.path)
531 index := 0
532 if len(f.lastSelected) > 0 {
533 index = f.lastSelected[len(f.lastSelected)-1]
534 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
535 }
536 f.cursor = index
537 f.activeView = filesViewFiles
538 f.code.SetSideNote("")
539 f.blameView = false
540 f.currentBlame = nil
541 f.code.UseGlamour = false
542 return f.updateFilesCmd
543}
544
545func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
546 return func() tea.Msg {
547 return FileItemsMsg(items)
548 }
549}