files.go

  1package repo
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"log"
  7	"path/filepath"
  8
  9	"github.com/alecthomas/chroma/lexers"
 10	"github.com/charmbracelet/bubbles/key"
 11	tea "github.com/charmbracelet/bubbletea"
 12	"github.com/charmbracelet/soft-serve/git"
 13	"github.com/charmbracelet/soft-serve/server/backend"
 14	"github.com/charmbracelet/soft-serve/ui/common"
 15	"github.com/charmbracelet/soft-serve/ui/components/code"
 16	"github.com/charmbracelet/soft-serve/ui/components/selector"
 17)
 18
 19type filesView int
 20
 21const (
 22	filesViewFiles filesView = iota
 23	filesViewContent
 24)
 25
 26var (
 27	errNoFileSelected = errors.New("no file selected")
 28	errBinaryFile     = errors.New("binary file")
 29	errFileTooLarge   = errors.New("file is too large")
 30	errInvalidFile    = errors.New("invalid file")
 31)
 32
 33var (
 34	lineNo = key.NewBinding(
 35		key.WithKeys("l"),
 36		key.WithHelp("l", "toggle line numbers"),
 37	)
 38)
 39
 40// FileItemsMsg is a message that contains a list of files.
 41type FileItemsMsg []selector.IdentifiableItem
 42
 43// FileContentMsg is a message that contains the content of a file.
 44type FileContentMsg struct {
 45	content string
 46	ext     string
 47}
 48
 49// Files is the model for the files view.
 50type Files struct {
 51	common         common.Common
 52	selector       *selector.Selector
 53	ref            *git.Reference
 54	activeView     filesView
 55	repo           backend.Repository
 56	code           *code.Code
 57	path           string
 58	currentItem    *FileItem
 59	currentContent FileContentMsg
 60	lastSelected   []int
 61	lineNumber     bool
 62}
 63
 64// NewFiles creates a new files model.
 65func NewFiles(common common.Common) *Files {
 66	f := &Files{
 67		common:       common,
 68		code:         code.New(common, "", ""),
 69		activeView:   filesViewFiles,
 70		lastSelected: make([]int, 0),
 71		lineNumber:   true,
 72	}
 73	selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common})
 74	selector.SetShowFilter(false)
 75	selector.SetShowHelp(false)
 76	selector.SetShowPagination(false)
 77	selector.SetShowStatusBar(false)
 78	selector.SetShowTitle(false)
 79	selector.SetFilteringEnabled(false)
 80	selector.DisableQuitKeybindings()
 81	selector.KeyMap.NextPage = common.KeyMap.NextPage
 82	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
 83	f.selector = selector
 84	f.code.SetShowLineNumber(f.lineNumber)
 85	return f
 86}
 87
 88// SetSize implements common.Component.
 89func (f *Files) SetSize(width, height int) {
 90	f.common.SetSize(width, height)
 91	f.selector.SetSize(width, height)
 92	f.code.SetSize(width, height)
 93}
 94
 95// ShortHelp implements help.KeyMap.
 96func (f *Files) ShortHelp() []key.Binding {
 97	k := f.selector.KeyMap
 98	switch f.activeView {
 99	case filesViewFiles:
100		copyKey := f.common.KeyMap.Copy
101		copyKey.SetHelp("c", "copy name")
102		return []key.Binding{
103			f.common.KeyMap.SelectItem,
104			f.common.KeyMap.BackItem,
105			k.CursorUp,
106			k.CursorDown,
107			copyKey,
108		}
109	case filesViewContent:
110		copyKey := f.common.KeyMap.Copy
111		copyKey.SetHelp("c", "copy content")
112		b := []key.Binding{
113			f.common.KeyMap.UpDown,
114			f.common.KeyMap.BackItem,
115			copyKey,
116		}
117		lexer := lexers.Match(f.currentContent.ext)
118		lang := ""
119		if lexer != nil && lexer.Config() != nil {
120			lang = lexer.Config().Name
121		}
122		if lang != "markdown" {
123			b = append(b, lineNo)
124		}
125		return b
126	default:
127		return []key.Binding{}
128	}
129}
130
131// FullHelp implements help.KeyMap.
132func (f *Files) FullHelp() [][]key.Binding {
133	b := make([][]key.Binding, 0)
134	copyKey := f.common.KeyMap.Copy
135	switch f.activeView {
136	case filesViewFiles:
137		copyKey.SetHelp("c", "copy name")
138		k := f.selector.KeyMap
139		b = append(b, []key.Binding{
140			f.common.KeyMap.SelectItem,
141			f.common.KeyMap.BackItem,
142		})
143		b = append(b, [][]key.Binding{
144			{
145				k.CursorUp,
146				k.CursorDown,
147				k.NextPage,
148				k.PrevPage,
149			},
150			{
151				k.GoToStart,
152				k.GoToEnd,
153				copyKey,
154			},
155		}...)
156	case filesViewContent:
157		copyKey.SetHelp("c", "copy content")
158		k := f.code.KeyMap
159		b = append(b, []key.Binding{
160			f.common.KeyMap.BackItem,
161		})
162		b = append(b, [][]key.Binding{
163			{
164				k.PageDown,
165				k.PageUp,
166				k.HalfPageDown,
167				k.HalfPageUp,
168			},
169		}...)
170		lc := []key.Binding{
171			k.Down,
172			k.Up,
173			f.common.KeyMap.GotoTop,
174			f.common.KeyMap.GotoBottom,
175			copyKey,
176		}
177		lexer := lexers.Match(f.currentContent.ext)
178		lang := ""
179		if lexer != nil && lexer.Config() != nil {
180			lang = lexer.Config().Name
181		}
182		if lang != "markdown" {
183			lc = append(lc, lineNo)
184		}
185		b = append(b, lc)
186	}
187	return b
188}
189
190// Init implements tea.Model.
191func (f *Files) Init() tea.Cmd {
192	f.path = ""
193	f.currentItem = nil
194	f.activeView = filesViewFiles
195	f.lastSelected = make([]int, 0)
196	f.selector.Select(0)
197	return f.updateFilesCmd
198}
199
200// Update implements tea.Model.
201func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
202	cmds := make([]tea.Cmd, 0)
203	switch msg := msg.(type) {
204	case RepoMsg:
205		f.repo = msg
206	case RefMsg:
207		f.ref = msg
208		cmds = append(cmds, f.Init())
209	case FileItemsMsg:
210		cmds = append(cmds,
211			f.selector.SetItems(msg),
212			updateStatusBarCmd,
213		)
214	case FileContentMsg:
215		f.activeView = filesViewContent
216		f.currentContent = msg
217		f.code.SetContent(msg.content, msg.ext)
218		f.code.GotoTop()
219		cmds = append(cmds, updateStatusBarCmd)
220	case selector.SelectMsg:
221		switch sel := msg.IdentifiableItem.(type) {
222		case FileItem:
223			f.currentItem = &sel
224			f.path = filepath.Join(f.path, sel.entry.Name())
225			if sel.entry.IsTree() {
226				cmds = append(cmds, f.selectTreeCmd)
227			} else {
228				cmds = append(cmds, f.selectFileCmd)
229			}
230		}
231	case BackMsg:
232		cmds = append(cmds, f.deselectItemCmd)
233	case tea.KeyMsg:
234		switch f.activeView {
235		case filesViewFiles:
236			switch {
237			case key.Matches(msg, f.common.KeyMap.SelectItem):
238				cmds = append(cmds, f.selector.SelectItem)
239			case key.Matches(msg, f.common.KeyMap.BackItem):
240				cmds = append(cmds, backCmd)
241			}
242		case filesViewContent:
243			switch {
244			case key.Matches(msg, f.common.KeyMap.BackItem):
245				cmds = append(cmds, backCmd)
246			case key.Matches(msg, f.common.KeyMap.Copy):
247				cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))
248			case key.Matches(msg, lineNo):
249				f.lineNumber = !f.lineNumber
250				f.code.SetShowLineNumber(f.lineNumber)
251				cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
252			}
253		}
254	case tea.WindowSizeMsg:
255		switch f.activeView {
256		case filesViewFiles:
257			if f.repo != nil {
258				cmds = append(cmds, f.updateFilesCmd)
259			}
260		case filesViewContent:
261			if f.currentContent.content != "" {
262				m, cmd := f.code.Update(msg)
263				f.code = m.(*code.Code)
264				if cmd != nil {
265					cmds = append(cmds, cmd)
266				}
267			}
268		}
269	case selector.ActiveMsg:
270		cmds = append(cmds, updateStatusBarCmd)
271	case EmptyRepoMsg:
272		f.ref = nil
273		f.path = ""
274		f.currentItem = nil
275		f.activeView = filesViewFiles
276		f.lastSelected = make([]int, 0)
277		f.selector.Select(0)
278		cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
279	}
280	switch f.activeView {
281	case filesViewFiles:
282		m, cmd := f.selector.Update(msg)
283		f.selector = m.(*selector.Selector)
284		if cmd != nil {
285			cmds = append(cmds, cmd)
286		}
287	case filesViewContent:
288		m, cmd := f.code.Update(msg)
289		f.code = m.(*code.Code)
290		if cmd != nil {
291			cmds = append(cmds, cmd)
292		}
293	}
294	return f, tea.Batch(cmds...)
295}
296
297// View implements tea.Model.
298func (f *Files) View() string {
299	switch f.activeView {
300	case filesViewFiles:
301		return f.selector.View()
302	case filesViewContent:
303		return f.code.View()
304	default:
305		return ""
306	}
307}
308
309// StatusBarValue returns the status bar value.
310func (f *Files) StatusBarValue() string {
311	p := f.path
312	if p == "." {
313		// FIXME: this is a hack to force clear the status bar value
314		return " "
315	}
316	return p
317}
318
319// StatusBarInfo returns the status bar info.
320func (f *Files) StatusBarInfo() string {
321	switch f.activeView {
322	case filesViewFiles:
323		return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
324	case filesViewContent:
325		return fmt.Sprintf("☰ %.f%%", f.code.ScrollPercent()*100)
326	default:
327		return ""
328	}
329}
330
331func (f *Files) updateFilesCmd() tea.Msg {
332	files := make([]selector.IdentifiableItem, 0)
333	dirs := make([]selector.IdentifiableItem, 0)
334	if f.ref == nil {
335		log.Printf("ui: files: ref is nil")
336		return common.ErrorMsg(errNoRef)
337	}
338	r, err := f.repo.Open()
339	if err != nil {
340		return common.ErrorMsg(err)
341	}
342	t, err := r.TreePath(f.ref, f.path)
343	if err != nil {
344		log.Printf("ui: files: error getting tree %v", err)
345		return common.ErrorMsg(err)
346	}
347	ents, err := t.Entries()
348	if err != nil {
349		log.Printf("ui: files: error listing files %v", err)
350		return common.ErrorMsg(err)
351	}
352	ents.Sort()
353	for _, e := range ents {
354		if e.IsTree() {
355			dirs = append(dirs, FileItem{entry: e})
356		} else {
357			files = append(files, FileItem{entry: e})
358		}
359	}
360	return FileItemsMsg(append(dirs, files...))
361}
362
363func (f *Files) selectTreeCmd() tea.Msg {
364	if f.currentItem != nil && f.currentItem.entry.IsTree() {
365		f.lastSelected = append(f.lastSelected, f.selector.Index())
366		f.selector.Select(0)
367		return f.updateFilesCmd()
368	}
369	log.Printf("ui: files: current item is not a tree")
370	return common.ErrorMsg(errNoFileSelected)
371}
372
373func (f *Files) selectFileCmd() tea.Msg {
374	i := f.currentItem
375	if i != nil && !i.entry.IsTree() {
376		fi := i.entry.File()
377		if i.Mode().IsDir() || f == nil {
378			log.Printf("ui: files: current item is not a file")
379			return common.ErrorMsg(errInvalidFile)
380		}
381		bin, err := fi.IsBinary()
382		if err != nil {
383			f.path = filepath.Dir(f.path)
384			log.Printf("ui: files: error checking if file is binary %v", err)
385			return common.ErrorMsg(err)
386		}
387		if bin {
388			f.path = filepath.Dir(f.path)
389			log.Printf("ui: files: file is binary")
390			return common.ErrorMsg(errBinaryFile)
391		}
392		c, err := fi.Bytes()
393		if err != nil {
394			f.path = filepath.Dir(f.path)
395			log.Printf("ui: files: error reading file %v", err)
396			return common.ErrorMsg(err)
397		}
398		f.lastSelected = append(f.lastSelected, f.selector.Index())
399		return FileContentMsg{string(c), i.entry.Name()}
400	}
401	log.Printf("ui: files: current item is not a file")
402	return common.ErrorMsg(errNoFileSelected)
403}
404
405func (f *Files) deselectItemCmd() tea.Msg {
406	f.path = filepath.Dir(f.path)
407	f.activeView = filesViewFiles
408	msg := f.updateFilesCmd()
409	index := 0
410	if len(f.lastSelected) > 0 {
411		index = f.lastSelected[len(f.lastSelected)-1]
412		f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
413	}
414	f.selector.Select(index)
415	return msg
416}
417
418func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
419	return func() tea.Msg {
420		return FileItemsMsg(items)
421	}
422}