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