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 }
272 case tea.KeyPressMsg:
273 switch f.activeView {
274 case filesViewFiles:
275 switch {
276 case key.Matches(msg, f.common.KeyMap.SelectItem):
277 cmds = append(cmds, f.selector.SelectItemCmd)
278 case key.Matches(msg, f.common.KeyMap.BackItem):
279 cmds = append(cmds, f.deselectItemCmd())
280 }
281 case filesViewContent:
282 switch {
283 case key.Matches(msg, f.common.KeyMap.BackItem):
284 cmds = append(cmds, f.deselectItemCmd())
285 case key.Matches(msg, f.common.KeyMap.Copy):
286 cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))
287 case key.Matches(msg, lineNo) && !f.code.UseGlamour:
288 f.lineNumber = !f.lineNumber
289 f.code.ShowLineNumber = f.lineNumber
290 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
291 case key.Matches(msg, blameView):
292 f.activeView = filesViewLoading
293 f.blameView = !f.blameView
294 if f.blameView {
295 cmds = append(cmds, f.fetchBlame)
296 } else {
297 f.activeView = filesViewContent
298 cmds = append(cmds, f.code.SetSideNote(""))
299 }
300 cmds = append(cmds, f.spinner.Tick)
301 case key.Matches(msg, preview) &&
302 common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) && !f.blameView:
303 f.code.UseGlamour = !f.code.UseGlamour
304 cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
305 }
306 }
307 case tea.WindowSizeMsg:
308 f.SetSize(msg.Width, msg.Height)
309 switch f.activeView {
310 case filesViewFiles:
311 if f.repo != nil {
312 cmds = append(cmds, f.updateFilesCmd)
313 }
314 case filesViewContent:
315 if f.currentContent.content != "" {
316 m, cmd := f.code.Update(msg)
317 f.code = m.(*code.Code)
318 if cmd != nil {
319 cmds = append(cmds, cmd)
320 }
321 }
322 }
323 case EmptyRepoMsg:
324 f.ref = nil
325 f.path = ""
326 f.currentItem = nil
327 f.activeView = filesViewFiles
328 f.lastSelected = make([]int, 0)
329 f.selector.Select(0)
330 cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
331 case spinner.TickMsg:
332 if f.activeView == filesViewLoading && f.spinner.ID() == msg.ID {
333 s, cmd := f.spinner.Update(msg)
334 f.spinner = s
335 if cmd != nil {
336 cmds = append(cmds, cmd)
337 }
338 }
339 }
340 switch f.activeView {
341 case filesViewFiles:
342 m, cmd := f.selector.Update(msg)
343 f.selector = m.(*selector.Selector)
344 if cmd != nil {
345 cmds = append(cmds, cmd)
346 }
347 case filesViewContent:
348 m, cmd := f.code.Update(msg)
349 f.code = m.(*code.Code)
350 if cmd != nil {
351 cmds = append(cmds, cmd)
352 }
353 }
354 return f, tea.Batch(cmds...)
355}
356
357// View implements tea.Model.
358func (f *Files) View() string {
359 switch f.activeView {
360 case filesViewLoading:
361 return renderLoading(f.common, f.spinner)
362 case filesViewFiles:
363 return f.selector.View()
364 case filesViewContent:
365 return f.code.View()
366 default:
367 return ""
368 }
369}
370
371// SpinnerID implements common.TabComponent.
372func (f *Files) SpinnerID() int {
373 return f.spinner.ID()
374}
375
376// StatusBarValue returns the status bar value.
377func (f *Files) StatusBarValue() string {
378 p := f.path
379 if p == "." || p == "" {
380 return " "
381 }
382 return p
383}
384
385// StatusBarInfo returns the status bar info.
386func (f *Files) StatusBarInfo() string {
387 switch f.activeView {
388 case filesViewFiles:
389 return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
390 case filesViewContent:
391 return common.ScrollPercent(f.code.ScrollPosition())
392 default:
393 return ""
394 }
395}
396
397func (f *Files) updateFilesCmd() tea.Msg {
398 files := make([]selector.IdentifiableItem, 0)
399 dirs := make([]selector.IdentifiableItem, 0)
400 if f.ref == nil {
401 return nil
402 }
403 r, err := f.repo.Open()
404 if err != nil {
405 return common.ErrorCmd(err)
406 }
407 path := f.path
408 ref := f.ref
409 t, err := r.TreePath(ref, path)
410 if err != nil {
411 return common.ErrorCmd(err)
412 }
413 ents, err := t.Entries()
414 if err != nil {
415 return common.ErrorCmd(err)
416 }
417 ents.Sort()
418 for _, e := range ents {
419 if e.IsTree() {
420 dirs = append(dirs, FileItem{entry: e})
421 } else {
422 files = append(files, FileItem{entry: e})
423 }
424 }
425 return FileItemsMsg(append(dirs, files...))
426}
427
428func (f *Files) selectTreeCmd() tea.Msg {
429 if f.currentItem != nil && f.currentItem.entry.IsTree() {
430 f.lastSelected = append(f.lastSelected, f.selector.Index())
431 f.cursor = 0
432 return f.updateFilesCmd()
433 }
434 return common.ErrorMsg(errNoFileSelected)
435}
436
437func (f *Files) selectFileCmd() tea.Msg {
438 i := f.currentItem
439 if i != nil && !i.entry.IsTree() {
440 fi := i.entry.File()
441 if i.Mode().IsDir() || f == nil {
442 return common.ErrorMsg(errInvalidFile)
443 }
444
445 var err error
446 var bin bool
447
448 r, err := f.repo.Open()
449 if err == nil {
450 attrs, err := r.CheckAttributes(f.ref, fi.Path())
451 if err == nil {
452 for _, attr := range attrs {
453 if (attr.Name == "binary" && attr.Value == "set") ||
454 (attr.Name == "text" && attr.Value == "unset") {
455 bin = true
456 break
457 }
458 }
459 }
460 }
461
462 if !bin {
463 bin, err = fi.IsBinary()
464 if err != nil {
465 f.path = filepath.Dir(f.path)
466 return common.ErrorMsg(err)
467 }
468 }
469
470 if bin {
471 f.path = filepath.Dir(f.path)
472 return common.ErrorMsg(errBinaryFile)
473 }
474
475 c, err := fi.Bytes()
476 if err != nil {
477 f.path = filepath.Dir(f.path)
478 return common.ErrorMsg(err)
479 }
480
481 f.lastSelected = append(f.lastSelected, f.selector.Index())
482 return FileContentMsg{string(c), i.entry.Name()}
483 }
484
485 return common.ErrorMsg(errNoFileSelected)
486}
487
488func (f *Files) fetchBlame() tea.Msg {
489 r, err := f.repo.Open()
490 if err != nil {
491 return common.ErrorMsg(err)
492 }
493
494 b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path())
495 if err != nil {
496 return common.ErrorMsg(err)
497 }
498
499 return FileBlameMsg(b)
500}
501
502func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string {
503 if f == nil || f.entry.IsTree() || b == nil {
504 return ""
505 }
506
507 lines := make([]string, 0)
508 i := 1
509 var prev string
510 for {
511 commit := b.Line(i)
512 if commit == nil {
513 break
514 }
515 who := fmt.Sprintf("%s <%s>", commit.Author.Name, commit.Author.Email)
516 line := fmt.Sprintf("%s %s %s",
517 c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]),
518 c.Styles.Tree.Blame.Message.Render(commit.Summary()),
519 c.Styles.Tree.Blame.Who.Render(who),
520 )
521 if line != prev {
522 lines = append(lines, line)
523 } else {
524 lines = append(lines, "")
525 }
526 prev = line
527 i++
528 }
529
530 return strings.Join(lines, "\n")
531}
532
533func (f *Files) deselectItemCmd() tea.Cmd {
534 f.path = filepath.Dir(f.path)
535 index := 0
536 if len(f.lastSelected) > 0 {
537 index = f.lastSelected[len(f.lastSelected)-1]
538 f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
539 }
540 f.cursor = index
541 f.activeView = filesViewFiles
542 f.code.SetSideNote("")
543 f.blameView = false
544 f.currentBlame = nil
545 f.code.UseGlamour = false
546 return f.updateFilesCmd
547}
548
549func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
550 return func() tea.Msg {
551 return FileItemsMsg(items)
552 }
553}