files.go

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