files.go

  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}