files.go

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