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