files.go

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