files.go

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