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