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}