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