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 {
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 case filesViewLoading:
146 return []key.Binding{}
147 default:
148 return []key.Binding{}
149 }
150}
151
152// FullHelp implements help.KeyMap.
153func (f *Files) FullHelp() [][]key.Binding {
154 b := make([][]key.Binding, 0)
155 copyKey := f.common.KeyMap.Copy
156 actionKeys := []key.Binding{}
157 switch f.activeView {
158 case filesViewFiles:
159 copyKey.SetHelp("c", "copy name")
160 k := f.selector.KeyMap
161 b = append(b, [][]key.Binding{
162 {
163 f.common.KeyMap.SelectItem,
164 f.common.KeyMap.BackItem,
165 },
166 {
167 k.CursorUp,
168 k.CursorDown,
169 k.NextPage,
170 k.PrevPage,
171 },
172 {
173 k.GoToStart,
174 k.GoToEnd,
175 },
176 }...)
177 case filesViewContent:
178 if !f.code.UseGlamour {
179 actionKeys = append(actionKeys, lineNo)
180 }
181 actionKeys = append(actionKeys, blameView)
182 if common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) &&
183 !f.blameView {
184 actionKeys = append(actionKeys, preview)
185 }
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 case filesViewLoading:
206 // No help keys when loading
207 }
208 actionKeys = append([]key.Binding{
209 copyKey,
210 }, actionKeys...)
211 return append(b, actionKeys)
212}
213
214// Init implements tea.Model.
215func (f *Files) Init() tea.Cmd {
216 f.path = ""
217 f.currentItem = nil
218 f.activeView = filesViewLoading
219 f.lastSelected = make([]int, 0)
220 f.blameView = false
221 f.currentBlame = nil
222 f.code.UseGlamour = false
223 return tea.Batch(f.spinner.Tick, f.updateFilesCmd)
224}
225
226// Update implements tea.Model.
227func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
228 cmds := make([]tea.Cmd, 0)
229 switch msg := msg.(type) {
230 case RepoMsg:
231 f.repo = msg
232 case RefMsg:
233 f.ref = msg
234 f.selector.Select(0)
235 cmds = append(cmds, f.Init())
236 case FileItemsMsg:
237 cmds = append(cmds,
238 f.selector.SetItems(msg),
239 )
240 f.activeView = filesViewFiles
241 if f.cursor >= 0 {
242 f.selector.Select(f.cursor)
243 f.cursor = -1
244 }
245 case FileContentMsg:
246 f.activeView = filesViewContent
247 f.currentContent = msg
248 f.code.UseGlamour = common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext)
249 cmds = append(cmds, f.code.SetContent(msg.content, msg.ext))
250 f.code.GotoTop()
251 case FileBlameMsg:
252 f.currentBlame = msg
253 f.activeView = filesViewContent
254 f.code.UseGlamour = false
255 f.code.SetSideNote(renderBlame(f.common, f.currentItem, msg))
256 case selector.SelectMsg:
257 switch sel := msg.IdentifiableItem.(type) {
258 case FileItem:
259 f.currentItem = &sel
260 f.path = filepath.Join(f.path, sel.entry.Name())
261 if sel.entry.IsTree() {
262 cmds = append(cmds, f.selectTreeCmd)
263 } else {
264 cmds = append(cmds, f.selectFileCmd)
265 }
266 }
267 case GoBackMsg:
268 switch f.activeView {
269 case filesViewFiles, filesViewContent:
270 cmds = append(cmds, f.deselectItemCmd())
271 case filesViewLoading:
272 // Do nothing while loading
273 }
274 case tea.KeyPressMsg:
275 switch f.activeView {
276 case filesViewFiles:
277 switch {
278 case key.Matches(msg, f.common.KeyMap.SelectItem):
279 cmds = append(cmds, f.selector.SelectItemCmd)
280 case key.Matches(msg, f.common.KeyMap.BackItem):
281 cmds = append(cmds, f.deselectItemCmd())
282 }
283 case filesViewContent:
284 switch {
285 case key.Matches(msg, f.common.KeyMap.BackItem):
286 cmds = append(cmds, f.deselectItemCmd())
287 case key.Matches(msg, f.common.KeyMap.Copy):
288 cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))
289 case key.Matches(msg, lineNo) && !f.code.UseGlamour:
290 f.lineNumber = !f.lineNumber
291 f.code.ShowLineNumber = f.lineNumber
292 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
293 case key.Matches(msg, blameView):
294 f.activeView = filesViewLoading
295 f.blameView = !f.blameView
296 if f.blameView {
297 cmds = append(cmds, f.fetchBlame)
298 } else {
299 f.activeView = filesViewContent
300 cmds = append(cmds, f.code.SetSideNote(""))
301 }
302 cmds = append(cmds, f.spinner.Tick)
303 case key.Matches(msg, preview) &&
304 common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) && !f.blameView:
305 f.code.UseGlamour = !f.code.UseGlamour
306 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
307 }
308 case filesViewLoading:
309 // No key handling while loading
310 }
311 case tea.WindowSizeMsg:
312 f.SetSize(msg.Width, msg.Height)
313 switch f.activeView {
314 case filesViewFiles:
315 if f.repo != nil {
316 cmds = append(cmds, f.updateFilesCmd)
317 }
318 case filesViewContent:
319 if f.currentContent.content != "" {
320 m, cmd := f.code.Update(msg)
321 f.code = m.(*code.Code)
322 if cmd != nil {
323 cmds = append(cmds, cmd)
324 }
325 }
326 case filesViewLoading:
327 // Do nothing while loading
328 }
329 case EmptyRepoMsg:
330 f.ref = nil
331 f.path = ""
332 f.currentItem = nil
333 f.activeView = filesViewFiles
334 f.lastSelected = make([]int, 0)
335 f.selector.Select(0)
336 cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
337 case spinner.TickMsg:
338 if f.activeView == filesViewLoading && f.spinner.ID() == msg.ID {
339 s, cmd := f.spinner.Update(msg)
340 f.spinner = s
341 if cmd != nil {
342 cmds = append(cmds, cmd)
343 }
344 }
345 }
346 switch f.activeView {
347 case filesViewFiles:
348 m, cmd := f.selector.Update(msg)
349 f.selector = m.(*selector.Selector)
350 if cmd != nil {
351 cmds = append(cmds, cmd)
352 }
353 case filesViewContent:
354 m, cmd := f.code.Update(msg)
355 f.code = m.(*code.Code)
356 if cmd != nil {
357 cmds = append(cmds, cmd)
358 }
359 case filesViewLoading:
360 m, cmd := f.spinner.Update(msg)
361 f.spinner = m
362 if cmd != nil {
363 cmds = append(cmds, cmd)
364 }
365 }
366 return f, tea.Batch(cmds...)
367}
368
369// View implements tea.Model.
370func (f *Files) View() string {
371 switch f.activeView {
372 case filesViewLoading:
373 return renderLoading(f.common, f.spinner)
374 case filesViewFiles:
375 return f.selector.View()
376 case filesViewContent:
377 return f.code.View()
378 default:
379 return ""
380 }
381}
382
383// SpinnerID implements common.TabComponent.
384func (f *Files) SpinnerID() int {
385 return f.spinner.ID()
386}
387
388// StatusBarValue returns the status bar value.
389func (f *Files) StatusBarValue() string {
390 p := f.path
391 if p == "." || p == "" {
392 return " "
393 }
394 return p
395}
396
397// StatusBarInfo returns the status bar info.
398func (f *Files) StatusBarInfo() string {
399 switch f.activeView {
400 case filesViewFiles:
401 return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
402 case filesViewContent:
403 return common.ScrollPercent(f.code.ScrollPosition())
404 case filesViewLoading:
405 return "Loading..."
406 default:
407 return ""
408 }
409}
410
411func (f *Files) updateFilesCmd() tea.Msg {
412 files := make([]selector.IdentifiableItem, 0)
413 dirs := make([]selector.IdentifiableItem, 0)
414 if f.ref == nil {
415 return nil
416 }
417 r, err := f.repo.Open()
418 if err != nil {
419 return common.ErrorCmd(err)
420 }
421 path := f.path
422 ref := f.ref
423 t, err := r.TreePath(ref, path)
424 if err != nil {
425 return common.ErrorCmd(err)
426 }
427 ents, err := t.Entries()
428 if err != nil {
429 return common.ErrorCmd(err)
430 }
431 ents.Sort()
432 for _, e := range ents {
433 if e.IsTree() {
434 dirs = append(dirs, FileItem{entry: e})
435 } else {
436 files = append(files, FileItem{entry: e})
437 }
438 }
439 return FileItemsMsg(append(dirs, files...))
440}
441
442func (f *Files) selectTreeCmd() tea.Msg {
443 if f.currentItem != nil && f.currentItem.entry.IsTree() {
444 f.lastSelected = append(f.lastSelected, f.selector.Index())
445 f.cursor = 0
446 return f.updateFilesCmd()
447 }
448 return common.ErrorMsg(errNoFileSelected)
449}
450
451func (f *Files) selectFileCmd() tea.Msg {
452 i := f.currentItem
453 if i != nil && !i.entry.IsTree() {
454 fi := i.entry.File()
455 if i.Mode().IsDir() || f == nil {
456 return common.ErrorMsg(errInvalidFile)
457 }
458
459 var err error
460 var bin bool
461
462 r, err := f.repo.Open()
463 if err == nil {
464 attrs, err := r.CheckAttributes(f.ref, fi.Path())
465 if err == nil {
466 for _, attr := range attrs {
467 if (attr.Name == "binary" && attr.Value == "set") ||
468 (attr.Name == "text" && attr.Value == "unset") {
469 bin = true
470 break
471 }
472 }
473 }
474 }
475
476 if !bin {
477 bin, err = fi.IsBinary()
478 if err != nil {
479 f.path = filepath.Dir(f.path)
480 return common.ErrorMsg(err)
481 }
482 }
483
484 if bin {
485 f.path = filepath.Dir(f.path)
486 return common.ErrorMsg(errBinaryFile)
487 }
488
489 c, err := fi.Bytes()
490 if err != nil {
491 f.path = filepath.Dir(f.path)
492 return common.ErrorMsg(err)
493 }
494
495 f.lastSelected = append(f.lastSelected, f.selector.Index())
496 return FileContentMsg{string(c), i.entry.Name()}
497 }
498
499 return common.ErrorMsg(errNoFileSelected)
500}
501
502func (f *Files) fetchBlame() tea.Msg {
503 r, err := f.repo.Open()
504 if err != nil {
505 return common.ErrorMsg(err)
506 }
507
508 b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path())
509 if err != nil {
510 return common.ErrorMsg(err)
511 }
512
513 return FileBlameMsg(b)
514}
515
516func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string {
517 if f == nil || f.entry.IsTree() || b == nil {
518 return ""
519 }
520
521 lines := make([]string, 0)
522 i := 1
523 var prev string
524 for {
525 commit := b.Line(i)
526 if commit == nil {
527 break
528 }
529 who := fmt.Sprintf("%s <%s>", commit.Author.Name, commit.Author.Email)
530 line := fmt.Sprintf("%s %s %s",
531 c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]),
532 c.Styles.Tree.Blame.Message.Render(commit.Summary()),
533 c.Styles.Tree.Blame.Who.Render(who),
534 )
535 if line != prev {
536 lines = append(lines, line)
537 } else {
538 lines = append(lines, "")
539 }
540 prev = line
541 i++
542 }
543
544 return strings.Join(lines, "\n")
545}
546
547func (f *Files) deselectItemCmd() tea.Cmd {
548 f.path = filepath.Dir(f.path)
549 index := 0
550 if len(f.lastSelected) > 0 {
551 index = f.lastSelected[len(f.lastSelected)-1]
552 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
553 }
554 f.cursor = index
555 f.activeView = filesViewFiles
556 f.code.SetSideNote("")
557 f.blameView = false
558 f.currentBlame = nil
559 f.code.UseGlamour = false
560 return f.updateFilesCmd
561}
562
563func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
564 return func() tea.Msg {
565 return FileItemsMsg(items)
566 }
567}