files.go

  1package repo
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"log"
  7	"path/filepath"
  8
  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.Styles})
 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// Init implements tea.Model.
 84func (f *Files) Init() tea.Cmd {
 85	f.path = ""
 86	f.currentItem = nil
 87	f.activeView = filesViewFiles
 88	f.lastSelected = make([]int, 0)
 89	return f.updateFilesCmd
 90}
 91
 92// Update implements tea.Model.
 93func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 94	cmds := make([]tea.Cmd, 0)
 95	switch msg := msg.(type) {
 96	case RepoMsg:
 97		f.selector.Select(0)
 98		f.repo = git.GitRepo(msg)
 99		cmds = append(cmds, f.Init())
100	case RefMsg:
101		f.ref = msg
102		cmds = append(cmds, f.Init())
103	case FileItemsMsg:
104		cmds = append(cmds,
105			f.selector.SetItems(msg),
106			updateStatusBarCmd,
107		)
108	case FileContentMsg:
109		f.activeView = filesViewContent
110		f.currentContent = msg
111		f.code.SetContent(msg.content, msg.ext)
112		f.code.GotoTop()
113		cmds = append(cmds, updateStatusBarCmd)
114	case selector.SelectMsg:
115		switch sel := msg.IdentifiableItem.(type) {
116		case FileItem:
117			f.currentItem = &sel
118			f.path = filepath.Join(f.path, sel.entry.Name())
119			log.Printf("selected index %d", f.selector.Index())
120			if sel.entry.IsTree() {
121				cmds = append(cmds, f.selectTreeCmd)
122			} else {
123				cmds = append(cmds, f.selectFileCmd)
124			}
125		}
126	case tea.KeyMsg:
127		switch f.activeView {
128		case filesViewFiles:
129			switch msg.String() {
130			case "l", "right":
131				cmds = append(cmds, f.selector.SelectItem)
132			case "h", "left":
133				cmds = append(cmds, f.deselectItemCmd)
134			}
135		case filesViewContent:
136			switch msg.String() {
137			case "h", "left":
138				cmds = append(cmds, f.deselectItemCmd)
139			}
140		}
141	case tea.WindowSizeMsg:
142		switch f.activeView {
143		case filesViewFiles:
144			if f.repo != nil {
145				cmds = append(cmds, f.updateFilesCmd)
146			}
147		case filesViewContent:
148			if f.currentContent.content != "" {
149				m, cmd := f.code.Update(msg)
150				f.code = m.(*code.Code)
151				if cmd != nil {
152					cmds = append(cmds, cmd)
153				}
154			}
155		}
156	}
157	switch f.activeView {
158	case filesViewFiles:
159		m, cmd := f.selector.Update(msg)
160		f.selector = m.(*selector.Selector)
161		if cmd != nil {
162			cmds = append(cmds, cmd)
163		}
164	case filesViewContent:
165		m, cmd := f.code.Update(msg)
166		f.code = m.(*code.Code)
167		if cmd != nil {
168			cmds = append(cmds, cmd)
169		}
170	}
171	return f, tea.Batch(cmds...)
172}
173
174// View implements tea.Model.
175func (f *Files) View() string {
176	switch f.activeView {
177	case filesViewFiles:
178		return f.selector.View()
179	case filesViewContent:
180		return f.code.View()
181	default:
182		return ""
183	}
184}
185
186// StatusBarValue returns the status bar value.
187func (f *Files) StatusBarValue() string {
188	p := f.path
189	if p == "." {
190		return ""
191	}
192	return p
193}
194
195// StatusBarInfo returns the status bar info.
196func (f *Files) StatusBarInfo() string {
197	switch f.activeView {
198	case filesViewFiles:
199		return fmt.Sprintf("%d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
200	case filesViewContent:
201		return fmt.Sprintf("%.f%%", f.code.ScrollPercent()*100)
202	default:
203		return ""
204	}
205}
206
207func (f *Files) updateFilesCmd() tea.Msg {
208	files := make([]selector.IdentifiableItem, 0)
209	dirs := make([]selector.IdentifiableItem, 0)
210	t, err := f.repo.Tree(f.ref, f.path)
211	if err != nil {
212		return common.ErrorMsg(err)
213	}
214	ents, err := t.Entries()
215	if err != nil {
216		return common.ErrorMsg(err)
217	}
218	ents.Sort()
219	for _, e := range ents {
220		if e.IsTree() {
221			dirs = append(dirs, FileItem{e})
222		} else {
223			files = append(files, FileItem{e})
224		}
225	}
226	return FileItemsMsg(append(dirs, files...))
227}
228
229func (f *Files) selectTreeCmd() tea.Msg {
230	if f.currentItem != nil && f.currentItem.entry.IsTree() {
231		f.lastSelected = append(f.lastSelected, f.selector.Index())
232		f.selector.Select(0)
233		return f.updateFilesCmd()
234	}
235	return common.ErrorMsg(errNoFileSelected)
236}
237
238func (f *Files) selectFileCmd() tea.Msg {
239	i := f.currentItem
240	if i != nil && !i.entry.IsTree() {
241		fi := i.entry.File()
242		if i.Mode().IsDir() || f == nil {
243			return common.ErrorMsg(errInvalidFile)
244		}
245		bin, err := fi.IsBinary()
246		if err != nil {
247			f.path = filepath.Dir(f.path)
248			return common.ErrorMsg(err)
249		}
250		if bin {
251			f.path = filepath.Dir(f.path)
252			return common.ErrorMsg(errBinaryFile)
253		}
254		c, err := fi.Bytes()
255		if err != nil {
256			f.path = filepath.Dir(f.path)
257			return common.ErrorMsg(err)
258		}
259		f.lastSelected = append(f.lastSelected, f.selector.Index())
260		return FileContentMsg{string(c), i.entry.Name()}
261	}
262	return common.ErrorMsg(errNoFileSelected)
263}
264
265func (f *Files) deselectItemCmd() tea.Msg {
266	f.path = filepath.Dir(f.path)
267	f.activeView = filesViewFiles
268	msg := f.updateFilesCmd()
269	index := 0
270	if len(f.lastSelected) > 0 {
271		index = f.lastSelected[len(f.lastSelected)-1]
272		f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
273	}
274	log.Printf("deselect %d", index)
275	f.selector.Select(index)
276	return msg
277}