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